From 3565988249fd6a92a96092ae62c20a7e44c71c9a Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Mon, 12 Oct 2026 11:38:21 +0900 Subject: [PATCH 1/2] Recognize sign-in failures for every agent and fall through Codex re-runs device login on revoked, expired, or reused sessions and token_revoked/401 errors, not only invalid_refresh_token. Claude and OpenCode credential rejections are classified too. Sign-in failures fall through to the next profile, and the failure comment names each tried profile with a fixed reason label. --- .agents/memory/agent-cli-auth-failures.md | 22 +++++++ src/auth.ts | 24 ++++++- src/auth_test.ts | 80 ++++++++++++++++++++++- src/opencode.ts | 39 ++++++++++- src/opencode_test.ts | 46 ++++++++++++- src/post.ts | 43 ++++++++++-- src/post_test.ts | 56 +++++++++++++--- src/profiles.ts | 34 ++++++++-- src/profiles_test.ts | 56 ++++++++++++++++ src/run.ts | 43 +++++++++--- src/runtime.ts | 4 +- 11 files changed, 411 insertions(+), 36 deletions(-) create mode 100644 .agents/memory/agent-cli-auth-failures.md diff --git a/.agents/memory/agent-cli-auth-failures.md b/.agents/memory/agent-cli-auth-failures.md new file mode 100644 index 0000000..22c07bb --- /dev/null +++ b/.agents/memory/agent-cli-auth-failures.md @@ -0,0 +1,22 @@ +--- +description: How codex, claude, and opencode report rejected credentials, and how the runner detects them +--- + +Observed 2026-10 (codex 0.153/0.162, Claude Code 2.1.28x, opencode 1.18/1.19): + +- Codex: `codex login status` only reads the local auth.json, so a + server-revoked ChatGPT login passes it. `codex exec` then exits 1 with stderr + ERROR lines such as `refresh_token_invalidated` / "Your session has ended. + Please log in again.", `auth error code: token_revoked`, `401 Unauthorized`; + a bad API key gives `invalid_api_key`. Codex echoes the whole prompt and tool + output to stderr, so `isCodexAuthFailure` only matches ERROR lines and the + runner strips the prompt first. +- Claude: a bad `CLAUDE_CODE_OAUTH_TOKEN` yields a stream-json `result` with + `is_error: true`, `api_error_status: 401`, and "Failed to authenticate. API + Error: 401 OAuth access token is invalid."; exit code 1, empty stderr. +- OpenCode: a bad OpenRouter key exits 1 with empty stderr and a stdout JSON + event `{"type":"error","error":{"name":"APIError","data":{"statusCode":401, + "message":"User not found."}}}`. An empty key gives an opaque UnknownError. + +Sign-in failures become `AgentAuthError` and fall through to the next profile +like usage exhaustion; the failure comment lists only fixed labels per profile. diff --git a/src/auth.ts b/src/auth.ts index d6427b7..657bf38 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -10,13 +10,31 @@ export function codexAuthentication(token?: string): CodexAuthentication { : { kind: "api-key", token }; } +// Codex logs revoked, expired, or reused ChatGPT sign-ins and rejected API keys +// on its own ERROR lines; tool output and the echoed prompt must not count. +const CODEX_AUTH_FAILURE = + /^(?:\S+\s+)?ERROR\b.*(?:\b401\b|unauthorized|invalid_refresh_token|refresh_token_(?:expired|reused|invalidated)|token_(?:revoked|expired)|invalid_api_key|(?:log|sign) in again)/im; + +export function isCodexAuthFailure(error: string): boolean { + return CODEX_AUTH_FAILURE.test(error); +} + +// Claude Code reports a rejected CLAUDE_CODE_OAUTH_TOKEN as an is_error +// stream-json result, e.g. "Failed to authenticate. API Error: 401 OAuth +// access token is invalid." with api_error_status 401. +export function isClaudeAuthFailure( + result: { api_error_status?: number | null; result?: string }, +): boolean { + return result.api_error_status === 401 || + /failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/i + .test(result.result ?? ""); +} + export async function retryInvalidToken< T extends { status: { success: boolean }; error: string }, >(attempt: () => Promise, relogin: () => Promise): Promise { let result = await attempt(); - if ( - !result.status.success && result.error.includes("invalid_refresh_token") - ) { + if (!result.status.success && isCodexAuthFailure(result.error)) { await relogin(); result = await attempt(); } diff --git a/src/auth_test.ts b/src/auth_test.ts index a581b62..179a511 100644 --- a/src/auth_test.ts +++ b/src/auth_test.ts @@ -1,6 +1,8 @@ import { assertEquals, assertRejects } from "jsr:@std/assert@1"; import { codexAuthentication, + isClaudeAuthFailure, + isCodexAuthFailure, persistCodexAuth, retryInvalidToken, stageCodexAuth, @@ -13,7 +15,9 @@ Deno.test("retries once after an invalid refresh token", async () => { () => Promise.resolve({ status: { success: false }, - error: attempts++ === 0 ? "invalid_refresh_token" : "still failed", + error: attempts++ === 0 + ? "ERROR: invalid_refresh_token" + : "still failed", }), () => { relogins++; @@ -48,6 +52,80 @@ Deno.test("does not retry an unrelated failure", async () => { assertEquals({ attempts, relogins }, { attempts: 1, relogins: 0 }); }); +// Verbatim stderr lines from the revoked-login job on lead/memona-home#36. +const REVOKED_CODEX = [ + `2026-10-12T02:28:04.702126Z ERROR codex_models_manager::manager: failed to refresh available models: unexpected status 401 Unauthorized: Encountered invalidated oauth token for user, failing request, url: https://chatgpt.com/backend-api/codex/models?client_version=0.162.1, cf-ray: a492ab48fdefea25-ICN, request id: req_4caa104fe9184a63bb8877f42817172d, auth error: 401, auth error code: token_revoked`, + `2026-10-12T02:28:05.082447Z ERROR codex_login::auth::manager: Failed to refresh token status=401 Unauthorized detail=TokenErrorDetail { error_code: Some("refresh_token_invalidated"), error_message: Some("Your session has ended. Please log in again."), .. }`, + `2026-10-12T02:28:05.451678Z ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when UnexpectedServerResponse("HTTP 401: {\\n \\"error\\": {\\n \\"message\\": \\"Encountered invalidated oauth token for user, failing request\\",\\n \\"type\\": null,\\n \\"code\\": \\"token_revoked\\",\\n \\"param\\": null\\n },\\n \\"status\\": 401\\n}")`, + "ERROR: workspace routing discovery unauthorized (401)", +]; + +Deno.test("recognizes each Codex sign-in failure line", () => { + for ( + const line of [ + ...REVOKED_CODEX, + `2026-10-12T02:28:05Z ERROR codex_login::auth::manager: Failed to refresh token status=401 Unauthorized detail=TokenErrorDetail { error_code: Some("refresh_token_expired"), .. }`, + `ERROR: Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again. (refresh_token_reused)`, + "ERROR: unexpected status 401 Unauthorized: Incorrect API key provided: sk-inval*d123., auth error: 401, auth error code: invalid_api_key", + ] + ) { + assertEquals(isCodexAuthFailure(`exec\n${line}\n`), true, line); + } +}); + +Deno.test("ignores auth words outside Codex error lines", () => { + assertEquals( + isCodexAuthFailure( + "exec\ncurl returned 401 Unauthorized\ntoken_revoked in fixture\n" + + "ERROR: tests failed\n", + ), + false, + ); +}); + +Deno.test("re-logs in after the job 9837 revoked-session stderr", async () => { + let relogins = 0; + let attempts = 0; + const result = await retryInvalidToken( + () => + Promise.resolve({ + status: { success: attempts++ > 0 }, + error: attempts === 1 ? REVOKED_CODEX.join("\n") : "", + }), + () => { + relogins++; + return Promise.resolve(); + }, + ); + + assertEquals({ relogins, success: result.status.success }, { + relogins: 1, + success: true, + }); +}); + +Deno.test("recognizes a rejected Claude OAuth token", () => { + assertEquals( + isClaudeAuthFailure({ + api_error_status: 401, + result: + "Failed to authenticate. API Error: 401 OAuth access token is invalid.", + }), + true, + ); + assertEquals( + isClaudeAuthFailure({ + result: + "OAuth token has expired. Please obtain a new token or refresh your existing token.", + }), + true, + ); + assertEquals( + isClaudeAuthFailure({ api_error_status: 429, result: "API Error: 429" }), + false, + ); +}); + Deno.test("selects API-key login only for a configured Codex token", () => { assertEquals(codexAuthentication("selected-key"), { kind: "api-key", diff --git a/src/opencode.ts b/src/opencode.ts index 2ee1b99..82074f7 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -1,3 +1,5 @@ +import { AgentAuthError } from "./profiles.ts"; + export function openCodeArgs(model: string, prompt: string): string[] { return [ "run", @@ -43,6 +45,38 @@ export async function collectOpenCodeOutput( return { stdout: output, stderr: error }; } +// OpenCode reports provider failures, including a rejected OpenRouter key +// (401 "User not found."), as a JSON error event on stdout and exits 1 with an +// empty stderr. +export function openCodeError(output: string): Error | undefined { + let failure: Error | undefined; + for (const line of output.split("\n")) { + if (line.trim() === "") continue; + let event: unknown; + try { + event = JSON.parse(line); + } catch { + continue; + } + if (typeof event !== "object" || event === null) continue; + const { type, error } = event as Record; + if (type !== "error" || typeof error !== "object" || error === null) { + continue; + } + const { name, data } = error as Record; + const { message, statusCode } = (data ?? {}) as Record; + const detail = `opencode ${typeof name === "string" ? name : "error"}${ + typeof statusCode === "number" ? ` (API error ${statusCode})` : "" + }: ${typeof message === "string" ? message : "unknown error"}`; + failure = statusCode === 401 || statusCode === 403 + ? new AgentAuthError("opencode sign-in failed", { + cause: new Error(detail), + }) + : new Error(detail); + } + return failure; +} + export function parseOpenCodeEvents(output: string): string { let result: string | undefined; for (const line of output.split("\n")) { @@ -54,6 +88,9 @@ export function parseOpenCodeEvents(output: string): string { const text = (part as Record).text; if (typeof text === "string" && text.trim() !== "") result = text; } - if (result === undefined) throw new Error("OpenCode produced no text output"); + if (result === undefined) { + throw openCodeError(output) ?? + new Error("OpenCode produced no text output"); + } return result; } diff --git a/src/opencode_test.ts b/src/opencode_test.ts index 808fa98..3b9750d 100644 --- a/src/opencode_test.ts +++ b/src/opencode_test.ts @@ -1,9 +1,16 @@ -import { assertEquals, assertRejects } from "jsr:@std/assert@1"; +import { + assertEquals, + assertInstanceOf, + assertRejects, + assertThrows, +} from "jsr:@std/assert@1"; import { collectOpenCodeOutput, openCodeArgs, + openCodeError, parseOpenCodeEvents, } from "./opencode.ts"; +import { AgentAuthError } from "./profiles.ts"; Deno.test("builds a non-interactive auto-approved OpenCode command", () => { assertEquals( @@ -74,3 +81,40 @@ Deno.test("streams OpenCode output while collecting it", async () => { assertEquals(result, { stdout: "first\nsecond\n", stderr: "warning\n" }); assertEquals(shown, ["out:first\n", "err:warning\n", "out:second\n"]); }); + +const openRouterError = (statusCode: number, message: string): string => + JSON.stringify({ + type: "error", + sessionID: "ses_test", + error: { + name: "APIError", + data: { + message, + statusCode, + isRetryable: false, + responseBody: JSON.stringify({ error: { message, code: statusCode } }), + }, + }, + }); + +Deno.test("reports a rejected OpenRouter key as a sign-in failure", () => { + const error = openCodeError(openRouterError(401, "User not found.")); + assertInstanceOf(error, AgentAuthError); + assertEquals(error.message, "opencode sign-in failed"); + assertEquals( + (error.cause as Error).message, + "opencode APIError (API error 401): User not found.", + ); +}); + +Deno.test("surfaces an OpenCode error event instead of empty output", () => { + assertThrows( + () => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded")), + Error, + "API error 429", + ); + assertEquals( + openCodeError('{"type":"text","part":{"text":"ok"}}'), + undefined, + ); +}); diff --git a/src/post.ts b/src/post.ts index e1e3083..ef176da 100644 --- a/src/post.ts +++ b/src/post.ts @@ -4,7 +4,12 @@ import { type RuntimeManifest, type RuntimeOutcome, } from "./runtime.ts"; -import { withProfile } from "./profiles.ts"; +import { + failureFooter, + type FailureReason, + type ProfileFailure, + withProfile, +} from "./profiles.ts"; export type PostContext = { apiUrl: string; @@ -26,7 +31,10 @@ const VERDICTS: Record = { Approved: ["APPROVED", "✅"], "Changes requested": ["REQUEST_CHANGES", "🛑"], }; -const FAILURE = "AI bot failed.\n\n> Profile: none · all profiles failed"; +const PROFILE_TYPES = ["claude", "codex", "opencode"]; +const FAILURE_REASONS = ["sign-in failed", "usage exhausted", "failed"]; +const failureComment = (failures: ProfileFailure[]): string => + `AI bot failed.\n\n${failureFooter(failures)}`; class GiteaRequestError extends Error {} @@ -61,7 +69,28 @@ async function readOutcome(path: string): Promise { throw new Error("invalid agent outcome"); } const outcome = value as Record; - if (outcome.ok === false) return { ok: false }; + if (outcome.ok === false) { + // Only known labels reach the comment, never raw agent output. + const failures = Array.isArray(outcome.failures) ? outcome.failures : []; + return { + ok: false, + failures: failures.flatMap((entry: unknown) => { + const { type, model, reason } = (entry ?? {}) as Record< + string, + unknown + >; + return typeof type === "string" && PROFILE_TYPES.includes(type) && + typeof model === "string" && typeof reason === "string" && + FAILURE_REASONS.includes(reason) + ? [{ + type: type as ProfileFailure["type"], + model, + reason: reason as FailureReason, + }] + : []; + }), + }; + } if ( outcome.ok !== true || typeof outcome.body !== "string" || typeof outcome.reviewPath !== "string" || @@ -152,11 +181,11 @@ export async function postRuntime( try { outcome = await readOutcome(manifest.outcomeFile); } catch (error) { - await postComment(FAILURE); + await postComment(failureComment([])); throw new Error("agent phase failed", { cause: error }); } if (!outcome.ok) { - await postComment(FAILURE); + await postComment(failureComment(outcome.failures)); throw new Error("agent phase failed"); } @@ -208,7 +237,9 @@ export async function postRuntime( }); } catch (error) { if (error instanceof GiteaRequestError) throw error; - await postComment(FAILURE); + await postComment( + failureComment([{ ...outcome.profile, reason: "failed" }]), + ); throw error; } } finally { diff --git a/src/post_test.ts b/src/post_test.ts index 13d6874..4931ff6 100644 --- a/src/post_test.ts +++ b/src/post_test.ts @@ -10,7 +10,7 @@ const context: PostContext = { }; async function fixture( - source: { ok: false } | { ok: true; body: string }, + source: Extract | { ok: true; body: string }, review?: string, anchors?: unknown, ): Promise<{ @@ -138,8 +138,15 @@ Deno.test("retries a rejected inline review without anchors", async () => { assert(await missing(result.runtimeDir)); }); -Deno.test("reports a generic agent failure and keeps the action failed", async () => { - const result = await fixture({ ok: false }); +Deno.test("reports each tried profile and keeps the action failed", async () => { + const result = await fixture({ + ok: false, + failures: [ + { type: "codex", model: "gpt-test", reason: "sign-in failed" }, + { type: "claude", model: "claude-test", reason: "usage exhausted" }, + { type: "opencode", model: "openrouter/test", reason: "failed" }, + ], + }); const bodies: unknown[] = []; await assertRejects( @@ -153,14 +160,47 @@ Deno.test("reports a generic agent failure and keeps the action failed", async ( ); assertEquals(bodies, [{ - body: "AI bot failed.\n\n> Profile: none · all profiles failed", + body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (sign-in failed), " + + "`claude` · `claude-test` (usage exhausted), " + + "`opencode` · `openrouter/test` (failed)", }]); assert(!JSON.stringify(bodies).includes("reviewer-secret")); assert(await missing(result.runtimeDir)); }); +Deno.test("drops failure entries that are not known labels", async () => { + const result = await fixture({ ok: false, failures: [] }); + await Deno.writeTextFile( + result.outcomeFile, + JSON.stringify({ + ok: false, + failures: [ + { type: "codex", model: "gpt-test", reason: "token sk-secret leaked" }, + { type: "shell", model: "x", reason: "failed" }, + { type: "claude", model: "claude-test", reason: "sign-in failed" }, + ], + }), + ); + const bodies: unknown[] = []; + + await assertRejects( + () => + postRuntime(result.manifestPath, context, (_url, init) => { + bodies.push(JSON.parse(String(init?.body))); + return Promise.resolve(new Response(null, { status: 201 })); + }), + Error, + "agent phase failed", + ); + + assertEquals(bodies, [{ + body: + "AI bot failed.\n\n> Tried: `claude` · `claude-test` (sign-in failed)", + }]); +}); + Deno.test("reports a generic failure when the runner wrote no outcome", async () => { - const result = await fixture({ ok: false }); + const result = await fixture({ ok: false, failures: [] }); await Deno.remove(result.outcomeFile); const bodies: unknown[] = []; @@ -174,9 +214,7 @@ Deno.test("reports a generic failure when the runner wrote no outcome", async () "agent phase failed", ); - assertEquals(bodies, [{ - body: "AI bot failed.\n\n> Profile: none · all profiles failed", - }]); + assertEquals(bodies, [{ body: "AI bot failed.\n\n> Profile: none" }]); assert(await missing(result.runtimeDir)); }); @@ -252,7 +290,7 @@ Deno.test("reports an attributed failure when a pull request review is missing", ); assertEquals(bodies, [{ - body: "AI bot failed.\n\n> Profile: none · all profiles failed", + body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (failed)", }]); assert(await missing(result.runtimeDir)); }); diff --git a/src/profiles.ts b/src/profiles.ts index 66c2352..c202f90 100644 --- a/src/profiles.ts +++ b/src/profiles.ts @@ -17,26 +17,52 @@ export function withProfile( return `${body}\n\n${profileFooter(profile)}`; } +export type FailureReason = "sign-in failed" | "usage exhausted" | "failed"; + +export type ProfileFailure = Pick & { + reason: FailureReason; +}; + +// Raised once an agent's credentials are rejected and any re-login is spent. +export class AgentAuthError extends Error {} + function isUsageExhausted(error: unknown): boolean { return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/i .test(String(error)); } +function failureReason(error: unknown): FailureReason { + if (error instanceof AgentAuthError) return "sign-in failed"; + return isUsageExhausted(error) ? "usage exhausted" : "failed"; +} + +export function failureFooter(failures: ProfileFailure[]): string { + if (failures.length === 0) return "> Profile: none"; + return `> Tried: ${ + failures + .map(({ type, model, reason }) => + `\`${type}\` · \`${model}\` (${reason})` + ) + .join(", ") + }`; +} + export async function runProfiles< T, P extends Pick, >( profiles: P[], attempt: (profile: P) => Promise, + failures: ProfileFailure[] = [], ): Promise { for (const [index, profile] of profiles.entries()) { try { return await attempt(profile); } catch (error) { - if (!isUsageExhausted(error) || index === profiles.length - 1) { - throw error; - } - console.error(`${profile.type} usage exhausted; trying next profile`); + const reason = failureReason(error); + failures.push({ type: profile.type, model: profile.model, reason }); + if (reason === "failed" || index === profiles.length - 1) throw error; + console.error(`${profile.type} ${reason}; trying next profile`); } } throw new Error("profiles must contain at least one profile"); diff --git a/src/profiles_test.ts b/src/profiles_test.ts index a87ba25..cd1d85e 100644 --- a/src/profiles_test.ts +++ b/src/profiles_test.ts @@ -1,6 +1,9 @@ import { assertEquals, assertRejects } from "jsr:@std/assert@1"; import { + AgentAuthError, agentEnvironment, + failureFooter, + type ProfileFailure, profileFooter, runProfiles, sanitizedEnvironment, @@ -70,6 +73,59 @@ Deno.test("recognizes provider API exhaustion status", async () => { assertEquals(results, ["fallback", "fallback"]); }); +Deno.test("falls through after a sign-in failure and records each try", async () => { + const failures: ProfileFailure[] = []; + const result = await runProfiles( + [ + { type: "codex", model: "gpt-5.6-terra" }, + { type: "claude", model: "claude-sonnet-5", token: "first" }, + { type: "opencode", model: "openrouter/qwen/qwen3.8-27b" }, + ], + (profile) => { + if (profile.type === "codex") { + throw new AgentAuthError("codex sign-in failed"); + } + if (profile.type === "claude") throw new Error("API Error: 429"); + return Promise.resolve("fallback"); + }, + failures, + ); + + assertEquals(result, "fallback"); + assertEquals(failures, [ + { type: "codex", model: "gpt-5.6-terra", reason: "sign-in failed" }, + { type: "claude", model: "claude-sonnet-5", reason: "usage exhausted" }, + ]); + assertEquals( + failureFooter(failures), + "> Tried: `codex` · `gpt-5.6-terra` (sign-in failed), " + + "`claude` · `claude-sonnet-5` (usage exhausted)", + ); +}); + +Deno.test("throws the last sign-in failure when every profile fails", async () => { + const failures: ProfileFailure[] = []; + await assertRejects( + () => + runProfiles( + [ + { type: "codex", model: "gpt-5.6-terra" }, + { type: "claude", model: "claude-sonnet-5", token: "first" }, + ], + (profile) => { + throw new AgentAuthError(`${profile.type} sign-in failed`); + }, + failures, + ), + AgentAuthError, + "claude sign-in failed", + ); + assertEquals(failures.map(({ reason }) => reason), [ + "sign-in failed", + "sign-in failed", + ]); +}); + Deno.test("preserves an ordinary agent failure", async () => { const visited: string[] = []; await assertRejects( diff --git a/src/run.ts b/src/run.ts index 3c05576..fade1eb 100644 --- a/src/run.ts +++ b/src/run.ts @@ -4,6 +4,8 @@ import { TextLineStream } from "jsr:@std/streams@1/text-line-stream"; import { type CodexAuthentication, codexAuthentication, + isClaudeAuthFailure, + isCodexAuthFailure, persistCodexAuth, retryInvalidToken, stageCodexAuth, @@ -14,7 +16,9 @@ import { readProfileFile, } from "./isolation.ts"; import { + AgentAuthError, agentEnvironment, + type ProfileFailure, runProfiles, sanitizedEnvironment, withProfile, @@ -22,6 +26,7 @@ import { import { collectOpenCodeOutput, openCodeArgs, + openCodeError, parseOpenCodeEvents, } from "./opencode.ts"; import { @@ -195,7 +200,12 @@ async function runClaude( stderr: "piped", }).spawn(); let result: - | { is_error?: boolean; result?: string; subtype: string } + | { + is_error?: boolean; + result?: string; + subtype: string; + api_error_status?: number | null; + } | undefined; let error = ""; const copyError = claude.stderr.pipeThrough(new TextDecoderStream()).pipeTo( @@ -220,7 +230,13 @@ async function runClaude( } const [status] = await Promise.all([claude.status, copyError]); if (result?.is_error) { - throw new Error(result.result ?? `claude ended with ${result.subtype}`); + const failure = new Error( + result.result ?? `claude ended with ${result.subtype}`, + ); + if (isClaudeAuthFailure(result)) { + throw new AgentAuthError("claude sign-in failed", { cause: failure }); + } + throw failure; } if (result?.result === undefined) { throw new Error( @@ -257,12 +273,14 @@ async function codexDeviceLogin( status.then(() => true), new Promise((resolve) => setTimeout(() => resolve(false), 100)), ]); - if (exited) throw new Error("AI bot login produced no instructions"); + if (exited) { + throw new AgentAuthError("AI bot login produced no instructions"); + } } await postAuthorComment(withProfile(shown, profile)); const [result] = await Promise.all([status, drained]); if (!result.success) { - throw new Error(`codex login exited with ${result.code}`); + throw new AgentAuthError(`codex login exited with ${result.code}`); } } @@ -347,7 +365,8 @@ async function runCodex( error += decoder.decode(chunk, { stream: true }); } error += decoder.decode(); - return { status: await codex.status, error }; + // Codex echoes the prompt, whose comments may quote auth errors. + return { status: await codex.status, error: error.replaceAll(prompt, "") }; }; const result = authentication.kind === "api-key" ? await attempt() @@ -356,9 +375,13 @@ async function runCodex( await codexDeviceLogin(profile, environment); }); if (!result.status.success) { - throw new Error( + const failure = new Error( result.error.trim() || `codex exited with ${result.status.code}`, ); + if (isCodexAuthFailure(result.error)) { + throw new AgentAuthError("codex sign-in failed", { cause: failure }); + } + throw failure; } return outputPath; } @@ -393,7 +416,8 @@ async function runOpenCode( const [status, streams] = await Promise.all([process.status, output]); const error = streams.stderr.trim(); if (!status.success) { - throw new Error(error || `opencode exited with ${status.code}`); + throw openCodeError(streams.stdout) ?? + new Error(error || `opencode exited with ${status.code}`); } return parseOpenCodeEvents(streams.stdout); } @@ -455,14 +479,15 @@ async function writeOutcome(outcome: RuntimeOutcome): Promise { await Deno.chmod(MANIFEST.outcomeFile, 0o600); } +const failures: ProfileFailure[] = []; try { await configureGitAuthor(); await writeOutcome({ ok: true, - ...await runProfiles(MANIFEST.profiles, runProfile), + ...await runProfiles(MANIFEST.profiles, runProfile, failures), }); } catch (error) { console.error(error); - await writeOutcome({ ok: false }); + await writeOutcome({ ok: false, failures }); throw error; } diff --git a/src/runtime.ts b/src/runtime.ts index 9c7ac24..7ced165 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,6 +1,6 @@ import { join } from "jsr:@std/path@1"; import { parseCredentials } from "./credentials.ts"; -import type { BotProfile } from "./profiles.ts"; +import type { BotProfile, ProfileFailure } from "./profiles.ts"; const FIRST_PROFILE_UID = 20_000; @@ -28,7 +28,7 @@ export type RuntimeOutcome = anchorsPath: string; profile: Pick; } - | { ok: false }; + | { ok: false; failures: ProfileFailure[] }; async function writePrivate( path: string, -- 2.52.0 From c91710a00ed0ca8c2ee2ebacbf6edc942671c5b4 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Mon, 12 Oct 2026 11:39:51 +0900 Subject: [PATCH 2/2] Format the CLI auth memory note --- .agents/memory/agent-cli-auth-failures.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.agents/memory/agent-cli-auth-failures.md b/.agents/memory/agent-cli-auth-failures.md index 22c07bb..0889ffc 100644 --- a/.agents/memory/agent-cli-auth-failures.md +++ b/.agents/memory/agent-cli-auth-failures.md @@ -7,16 +7,18 @@ Observed 2026-10 (codex 0.153/0.162, Claude Code 2.1.28x, opencode 1.18/1.19): - Codex: `codex login status` only reads the local auth.json, so a server-revoked ChatGPT login passes it. `codex exec` then exits 1 with stderr ERROR lines such as `refresh_token_invalidated` / "Your session has ended. - Please log in again.", `auth error code: token_revoked`, `401 Unauthorized`; - a bad API key gives `invalid_api_key`. Codex echoes the whole prompt and tool + Please log in again.", `auth error code: token_revoked`, `401 Unauthorized`; a + bad API key gives `invalid_api_key`. Codex echoes the whole prompt and tool output to stderr, so `isCodexAuthFailure` only matches ERROR lines and the runner strips the prompt first. - Claude: a bad `CLAUDE_CODE_OAUTH_TOKEN` yields a stream-json `result` with `is_error: true`, `api_error_status: 401`, and "Failed to authenticate. API Error: 401 OAuth access token is invalid."; exit code 1, empty stderr. - OpenCode: a bad OpenRouter key exits 1 with empty stderr and a stdout JSON - event `{"type":"error","error":{"name":"APIError","data":{"statusCode":401, - "message":"User not found."}}}`. An empty key gives an opaque UnknownError. + event + `{"type":"error","error":{"name":"APIError","data":{"statusCode":401, + "message":"User not found."}}}`. + An empty key gives an opaque UnknownError. Sign-in failures become `AgentAuthError` and fall through to the next profile like usage exhaustion; the failure comment lists only fixed labels per profile. -- 2.52.0