Recognize sign-in failures for every agent and fall through
Agents / agent (pull_request) Has been cancelled
Check / deno (pull_request) Failing after 35s

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.
This commit is contained in:
Danny Kim
2026-10-12 11:38:21 +09:00
parent ea20f6d629
commit 3565988249
11 changed files with 411 additions and 36 deletions
+22
View File
@@ -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.
+21 -3
View File
@@ -10,13 +10,31 @@ export function codexAuthentication(token?: string): CodexAuthentication {
: { kind: "api-key", token }; : { 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< export async function retryInvalidToken<
T extends { status: { success: boolean }; error: string }, T extends { status: { success: boolean }; error: string },
>(attempt: () => Promise<T>, relogin: () => Promise<void>): Promise<T> { >(attempt: () => Promise<T>, relogin: () => Promise<void>): Promise<T> {
let result = await attempt(); let result = await attempt();
if ( if (!result.status.success && isCodexAuthFailure(result.error)) {
!result.status.success && result.error.includes("invalid_refresh_token")
) {
await relogin(); await relogin();
result = await attempt(); result = await attempt();
} }
+79 -1
View File
@@ -1,6 +1,8 @@
import { assertEquals, assertRejects } from "jsr:@std/assert@1"; import { assertEquals, assertRejects } from "jsr:@std/assert@1";
import { import {
codexAuthentication, codexAuthentication,
isClaudeAuthFailure,
isCodexAuthFailure,
persistCodexAuth, persistCodexAuth,
retryInvalidToken, retryInvalidToken,
stageCodexAuth, stageCodexAuth,
@@ -13,7 +15,9 @@ Deno.test("retries once after an invalid refresh token", async () => {
() => () =>
Promise.resolve({ Promise.resolve({
status: { success: false }, status: { success: false },
error: attempts++ === 0 ? "invalid_refresh_token" : "still failed", error: attempts++ === 0
? "ERROR: invalid_refresh_token"
: "still failed",
}), }),
() => { () => {
relogins++; relogins++;
@@ -48,6 +52,80 @@ Deno.test("does not retry an unrelated failure", async () => {
assertEquals({ attempts, relogins }, { attempts: 1, relogins: 0 }); 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", () => { Deno.test("selects API-key login only for a configured Codex token", () => {
assertEquals(codexAuthentication("selected-key"), { assertEquals(codexAuthentication("selected-key"), {
kind: "api-key", kind: "api-key",
+38 -1
View File
@@ -1,3 +1,5 @@
import { AgentAuthError } from "./profiles.ts";
export function openCodeArgs(model: string, prompt: string): string[] { export function openCodeArgs(model: string, prompt: string): string[] {
return [ return [
"run", "run",
@@ -43,6 +45,38 @@ export async function collectOpenCodeOutput(
return { stdout: output, stderr: error }; 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<string, unknown>;
if (type !== "error" || typeof error !== "object" || error === null) {
continue;
}
const { name, data } = error as Record<string, unknown>;
const { message, statusCode } = (data ?? {}) as Record<string, unknown>;
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 { export function parseOpenCodeEvents(output: string): string {
let result: string | undefined; let result: string | undefined;
for (const line of output.split("\n")) { for (const line of output.split("\n")) {
@@ -54,6 +88,9 @@ export function parseOpenCodeEvents(output: string): string {
const text = (part as Record<string, unknown>).text; const text = (part as Record<string, unknown>).text;
if (typeof text === "string" && text.trim() !== "") result = 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; return result;
} }
+45 -1
View File
@@ -1,9 +1,16 @@
import { assertEquals, assertRejects } from "jsr:@std/assert@1"; import {
assertEquals,
assertInstanceOf,
assertRejects,
assertThrows,
} from "jsr:@std/assert@1";
import { import {
collectOpenCodeOutput, collectOpenCodeOutput,
openCodeArgs, openCodeArgs,
openCodeError,
parseOpenCodeEvents, parseOpenCodeEvents,
} from "./opencode.ts"; } from "./opencode.ts";
import { AgentAuthError } from "./profiles.ts";
Deno.test("builds a non-interactive auto-approved OpenCode command", () => { Deno.test("builds a non-interactive auto-approved OpenCode command", () => {
assertEquals( assertEquals(
@@ -74,3 +81,40 @@ Deno.test("streams OpenCode output while collecting it", async () => {
assertEquals(result, { stdout: "first\nsecond\n", stderr: "warning\n" }); assertEquals(result, { stdout: "first\nsecond\n", stderr: "warning\n" });
assertEquals(shown, ["out:first\n", "err:warning\n", "out:second\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,
);
});
+37 -6
View File
@@ -4,7 +4,12 @@ import {
type RuntimeManifest, type RuntimeManifest,
type RuntimeOutcome, type RuntimeOutcome,
} from "./runtime.ts"; } from "./runtime.ts";
import { withProfile } from "./profiles.ts"; import {
failureFooter,
type FailureReason,
type ProfileFailure,
withProfile,
} from "./profiles.ts";
export type PostContext = { export type PostContext = {
apiUrl: string; apiUrl: string;
@@ -26,7 +31,10 @@ const VERDICTS: Record<string, [event: string, mark: string]> = {
Approved: ["APPROVED", "✅"], Approved: ["APPROVED", "✅"],
"Changes requested": ["REQUEST_CHANGES", "🛑"], "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 {} class GiteaRequestError extends Error {}
@@ -61,7 +69,28 @@ async function readOutcome(path: string): Promise<RuntimeOutcome> {
throw new Error("invalid agent outcome"); throw new Error("invalid agent outcome");
} }
const outcome = value as Record<string, unknown>; const outcome = value as Record<string, unknown>;
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 ( if (
outcome.ok !== true || typeof outcome.body !== "string" || outcome.ok !== true || typeof outcome.body !== "string" ||
typeof outcome.reviewPath !== "string" || typeof outcome.reviewPath !== "string" ||
@@ -152,11 +181,11 @@ export async function postRuntime(
try { try {
outcome = await readOutcome(manifest.outcomeFile); outcome = await readOutcome(manifest.outcomeFile);
} catch (error) { } catch (error) {
await postComment(FAILURE); await postComment(failureComment([]));
throw new Error("agent phase failed", { cause: error }); throw new Error("agent phase failed", { cause: error });
} }
if (!outcome.ok) { if (!outcome.ok) {
await postComment(FAILURE); await postComment(failureComment(outcome.failures));
throw new Error("agent phase failed"); throw new Error("agent phase failed");
} }
@@ -208,7 +237,9 @@ export async function postRuntime(
}); });
} catch (error) { } catch (error) {
if (error instanceof GiteaRequestError) throw error; if (error instanceof GiteaRequestError) throw error;
await postComment(FAILURE); await postComment(
failureComment([{ ...outcome.profile, reason: "failed" }]),
);
throw error; throw error;
} }
} finally { } finally {
+47 -9
View File
@@ -10,7 +10,7 @@ const context: PostContext = {
}; };
async function fixture( async function fixture(
source: { ok: false } | { ok: true; body: string }, source: Extract<RuntimeOutcome, { ok: false }> | { ok: true; body: string },
review?: string, review?: string,
anchors?: unknown, anchors?: unknown,
): Promise<{ ): Promise<{
@@ -138,8 +138,15 @@ Deno.test("retries a rejected inline review without anchors", async () => {
assert(await missing(result.runtimeDir)); assert(await missing(result.runtimeDir));
}); });
Deno.test("reports a generic agent failure and keeps the action failed", async () => { Deno.test("reports each tried profile and keeps the action failed", async () => {
const result = await fixture({ ok: false }); 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[] = []; const bodies: unknown[] = [];
await assertRejects( await assertRejects(
@@ -153,14 +160,47 @@ Deno.test("reports a generic agent failure and keeps the action failed", async (
); );
assertEquals(bodies, [{ 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(!JSON.stringify(bodies).includes("reviewer-secret"));
assert(await missing(result.runtimeDir)); 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 () => { 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); await Deno.remove(result.outcomeFile);
const bodies: unknown[] = []; const bodies: unknown[] = [];
@@ -174,9 +214,7 @@ Deno.test("reports a generic failure when the runner wrote no outcome", async ()
"agent phase failed", "agent phase failed",
); );
assertEquals(bodies, [{ assertEquals(bodies, [{ body: "AI bot failed.\n\n> Profile: none" }]);
body: "AI bot failed.\n\n> Profile: none · all profiles failed",
}]);
assert(await missing(result.runtimeDir)); assert(await missing(result.runtimeDir));
}); });
@@ -252,7 +290,7 @@ Deno.test("reports an attributed failure when a pull request review is missing",
); );
assertEquals(bodies, [{ 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)); assert(await missing(result.runtimeDir));
}); });
+30 -4
View File
@@ -17,26 +17,52 @@ export function withProfile(
return `${body}\n\n${profileFooter(profile)}`; return `${body}\n\n${profileFooter(profile)}`;
} }
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed";
export type ProfileFailure = Pick<BotProfile, "type" | "model"> & {
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 { 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 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)); .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< export async function runProfiles<
T, T,
P extends Pick<BotProfile, "type" | "model">, P extends Pick<BotProfile, "type" | "model">,
>( >(
profiles: P[], profiles: P[],
attempt: (profile: P) => Promise<T>, attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[] = [],
): Promise<T> { ): Promise<T> {
for (const [index, profile] of profiles.entries()) { for (const [index, profile] of profiles.entries()) {
try { try {
return await attempt(profile); return await attempt(profile);
} catch (error) { } catch (error) {
if (!isUsageExhausted(error) || index === profiles.length - 1) { const reason = failureReason(error);
throw error; failures.push({ type: profile.type, model: profile.model, reason });
} if (reason === "failed" || index === profiles.length - 1) throw error;
console.error(`${profile.type} usage exhausted; trying next profile`); console.error(`${profile.type} ${reason}; trying next profile`);
} }
} }
throw new Error("profiles must contain at least one profile"); throw new Error("profiles must contain at least one profile");
+56
View File
@@ -1,6 +1,9 @@
import { assertEquals, assertRejects } from "jsr:@std/assert@1"; import { assertEquals, assertRejects } from "jsr:@std/assert@1";
import { import {
AgentAuthError,
agentEnvironment, agentEnvironment,
failureFooter,
type ProfileFailure,
profileFooter, profileFooter,
runProfiles, runProfiles,
sanitizedEnvironment, sanitizedEnvironment,
@@ -70,6 +73,59 @@ Deno.test("recognizes provider API exhaustion status", async () => {
assertEquals(results, ["fallback", "fallback"]); 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 () => { Deno.test("preserves an ordinary agent failure", async () => {
const visited: string[] = []; const visited: string[] = [];
await assertRejects( await assertRejects(
+34 -9
View File
@@ -4,6 +4,8 @@ import { TextLineStream } from "jsr:@std/streams@1/text-line-stream";
import { import {
type CodexAuthentication, type CodexAuthentication,
codexAuthentication, codexAuthentication,
isClaudeAuthFailure,
isCodexAuthFailure,
persistCodexAuth, persistCodexAuth,
retryInvalidToken, retryInvalidToken,
stageCodexAuth, stageCodexAuth,
@@ -14,7 +16,9 @@ import {
readProfileFile, readProfileFile,
} from "./isolation.ts"; } from "./isolation.ts";
import { import {
AgentAuthError,
agentEnvironment, agentEnvironment,
type ProfileFailure,
runProfiles, runProfiles,
sanitizedEnvironment, sanitizedEnvironment,
withProfile, withProfile,
@@ -22,6 +26,7 @@ import {
import { import {
collectOpenCodeOutput, collectOpenCodeOutput,
openCodeArgs, openCodeArgs,
openCodeError,
parseOpenCodeEvents, parseOpenCodeEvents,
} from "./opencode.ts"; } from "./opencode.ts";
import { import {
@@ -195,7 +200,12 @@ async function runClaude(
stderr: "piped", stderr: "piped",
}).spawn(); }).spawn();
let result: let result:
| { is_error?: boolean; result?: string; subtype: string } | {
is_error?: boolean;
result?: string;
subtype: string;
api_error_status?: number | null;
}
| undefined; | undefined;
let error = ""; let error = "";
const copyError = claude.stderr.pipeThrough(new TextDecoderStream()).pipeTo( const copyError = claude.stderr.pipeThrough(new TextDecoderStream()).pipeTo(
@@ -220,7 +230,13 @@ async function runClaude(
} }
const [status] = await Promise.all([claude.status, copyError]); const [status] = await Promise.all([claude.status, copyError]);
if (result?.is_error) { 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) { if (result?.result === undefined) {
throw new Error( throw new Error(
@@ -257,12 +273,14 @@ async function codexDeviceLogin(
status.then(() => true), status.then(() => true),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 100)), new Promise<boolean>((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)); await postAuthorComment(withProfile(shown, profile));
const [result] = await Promise.all([status, drained]); const [result] = await Promise.all([status, drained]);
if (!result.success) { 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(chunk, { stream: true });
} }
error += decoder.decode(); 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" const result = authentication.kind === "api-key"
? await attempt() ? await attempt()
@@ -356,9 +375,13 @@ async function runCodex(
await codexDeviceLogin(profile, environment); await codexDeviceLogin(profile, environment);
}); });
if (!result.status.success) { if (!result.status.success) {
throw new Error( const failure = new Error(
result.error.trim() || `codex exited with ${result.status.code}`, 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; return outputPath;
} }
@@ -393,7 +416,8 @@ async function runOpenCode(
const [status, streams] = await Promise.all([process.status, output]); const [status, streams] = await Promise.all([process.status, output]);
const error = streams.stderr.trim(); const error = streams.stderr.trim();
if (!status.success) { 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); return parseOpenCodeEvents(streams.stdout);
} }
@@ -455,14 +479,15 @@ async function writeOutcome(outcome: RuntimeOutcome): Promise<void> {
await Deno.chmod(MANIFEST.outcomeFile, 0o600); await Deno.chmod(MANIFEST.outcomeFile, 0o600);
} }
const failures: ProfileFailure[] = [];
try { try {
await configureGitAuthor(); await configureGitAuthor();
await writeOutcome({ await writeOutcome({
ok: true, ok: true,
...await runProfiles(MANIFEST.profiles, runProfile), ...await runProfiles(MANIFEST.profiles, runProfile, failures),
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
await writeOutcome({ ok: false }); await writeOutcome({ ok: false, failures });
throw error; throw error;
} }
+2 -2
View File
@@ -1,6 +1,6 @@
import { join } from "jsr:@std/path@1"; import { join } from "jsr:@std/path@1";
import { parseCredentials } from "./credentials.ts"; import { parseCredentials } from "./credentials.ts";
import type { BotProfile } from "./profiles.ts"; import type { BotProfile, ProfileFailure } from "./profiles.ts";
const FIRST_PROFILE_UID = 20_000; const FIRST_PROFILE_UID = 20_000;
@@ -28,7 +28,7 @@ export type RuntimeOutcome =
anchorsPath: string; anchorsPath: string;
profile: Pick<RuntimeProfile, "type" | "model">; profile: Pick<RuntimeProfile, "type" | "model">;
} }
| { ok: false }; | { ok: false; failures: ProfileFailure[] };
async function writePrivate( async function writePrivate(
path: string, path: string,