diff --git a/src/auth.ts b/src/auth.ts index 657bf38..f9e8648 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,13 +1,9 @@ import { dirname, join } from "jsr:@std/path@1"; -export type CodexAuthentication = - | { kind: "api-key"; token: string } - | { kind: "persistent" }; +export type CodexAuthentication = { kind: "api-key"; token: string } | { kind: "persistent" }; export function codexAuthentication(token?: string): CodexAuthentication { - return token === undefined - ? { kind: "persistent" } - : { kind: "api-key", token }; + return token === undefined ? { kind: "persistent" } : { kind: "api-key", token }; } // Codex logs revoked, expired, or reused ChatGPT sign-ins and rejected API keys @@ -22,17 +18,22 @@ export function isCodexAuthFailure(error: string): boolean { // 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 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 { +export async function retryInvalidToken( + attempt: () => Promise, + relogin: () => Promise, +): Promise { let result = await attempt(); if (!result.status.success && isCodexAuthFailure(result.error)) { await relogin(); @@ -59,9 +60,12 @@ async function readCodexAuth(path: string): Promise { const record = value as Record | null; const tokens = record?.tokens as Record | null; if ( - record === null || typeof record !== "object" || - typeof record.auth_mode !== "string" || record.auth_mode === "" || - tokens === null || typeof tokens !== "object" || + record === null || + typeof record !== "object" || + typeof record.auth_mode !== "string" || + record.auth_mode === "" || + tokens === null || + typeof tokens !== "object" || !["access_token", "refresh_token", "id_token", "account_id"].every( (name) => typeof tokens[name] === "string" && tokens[name] !== "", ) @@ -94,10 +98,7 @@ export async function stageCodexAuth( return stagedPath; } -export async function persistCodexAuth( - stagedPath: string, - canonicalPath: string, -): Promise { +export async function persistCodexAuth(stagedPath: string, canonicalPath: string): Promise { const text = await readCodexAuth(stagedPath); const directory = dirname(canonicalPath); await Deno.mkdir(directory, { recursive: true, mode: 0o700 }); diff --git a/src/auth_test.ts b/src/auth_test.ts index 179a511..8d19322 100644 --- a/src/auth_test.ts +++ b/src/auth_test.ts @@ -15,9 +15,7 @@ Deno.test("retries once after an invalid refresh token", async () => { () => Promise.resolve({ status: { success: false }, - error: attempts++ === 0 - ? "ERROR: invalid_refresh_token" - : "still failed", + error: attempts++ === 0 ? "ERROR: invalid_refresh_token" : "still failed", }), () => { relogins++; @@ -25,11 +23,14 @@ Deno.test("retries once after an invalid refresh token", async () => { }, ); - assertEquals({ attempts, relogins, error: result.error }, { - attempts: 2, - relogins: 1, - error: "still failed", - }); + assertEquals( + { attempts, relogins, error: result.error }, + { + attempts: 2, + relogins: 1, + error: "still failed", + }, + ); }); Deno.test("does not retry an unrelated failure", async () => { @@ -61,14 +62,12 @@ const REVOKED_CODEX = [ ]; 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", - ] - ) { + 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); } }); @@ -76,8 +75,7 @@ Deno.test("recognizes each Codex sign-in failure 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", + "exec\ncurl returned 401 Unauthorized\ntoken_revoked in fixture\n" + "ERROR: tests failed\n", ), false, ); @@ -98,32 +96,30 @@ Deno.test("re-logs in after the job 9837 revoked-session stderr", async () => { }, ); - assertEquals({ relogins, success: result.status.success }, { - relogins: 1, - success: true, - }); + 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.", + 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.", + 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, - ); + assertEquals(isClaudeAuthFailure({ api_error_status: 429, result: "API Error: 429" }), false); }); Deno.test("selects API-key login only for a configured Codex token", () => { @@ -154,12 +150,7 @@ Deno.test("stages and persists only a regular private Codex login", async () => const home = `${root}/profile`; await Deno.mkdir(home); - const staged = await stageCodexAuth( - canonical, - home, - Deno.uid()!, - Deno.gid()!, - ); + const staged = await stageCodexAuth(canonical, home, Deno.uid()!, Deno.gid()!); assertEquals(await Deno.readTextFile(staged), AUTH); assertEquals((await Deno.stat(staged)).mode! & 0o777, 0o600); @@ -187,16 +178,8 @@ Deno.test("rejects a symlink or malformed Codex login", async () => { await Deno.writeTextFile(malformed, '{"tokens":{}}', { mode: 0o600 }); await Deno.symlink(canonical, linked); - await assertRejects( - () => persistCodexAuth(linked, canonical), - Error, - "regular file", - ); - await assertRejects( - () => persistCodexAuth(malformed, canonical), - Error, - "invalid Codex login", - ); + await assertRejects(() => persistCodexAuth(linked, canonical), Error, "regular file"); + await assertRejects(() => persistCodexAuth(malformed, canonical), Error, "invalid Codex login"); } finally { await Deno.remove(root, { recursive: true }); } diff --git a/src/credentials.ts b/src/credentials.ts index bed63d6..4bcf49a 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -19,36 +19,22 @@ function decodeBase64Url(value: string): Uint8Array { } function encodeBase64Url(value: Uint8Array): string { - const binary = Array.from(value, (byte) => String.fromCharCode(byte)).join( - "", - ); - return btoa(binary).replaceAll("+", "-").replaceAll("/", "_") - .replaceAll("=", ""); + const binary = Array.from(value, (byte) => String.fromCharCode(byte)).join(""); + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); } -const buffer = (value: Uint8Array): ArrayBuffer => - Uint8Array.from(value).buffer; +const buffer = (value: Uint8Array): ArrayBuffer => Uint8Array.from(value).buffer; -async function importMasterKey( - encoded: string, - usage: KeyUsage, -): Promise { +async function importMasterKey(encoded: string, usage: KeyUsage): Promise { const bytes = decodeBase64Url(encoded); if (bytes.length !== 32) { throw new Error("master-key must be 32 bytes encoded as base64url"); } - return await crypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [ - usage, - ]); + return await crypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [usage]); } -export async function decryptCredential( - encrypted: string, - masterKey: string, -): Promise { - const [version, encodedNonce, encodedCiphertext, extra] = encrypted.split( - ":", - ); +export async function decryptCredential(encrypted: string, masterKey: string): Promise { + const [version, encodedNonce, encodedCiphertext, extra] = encrypted.split(":"); if (version !== "v1" || !encodedNonce || !encodedCiphertext || extra) { throw new Error("credential must use v1::"); } @@ -73,15 +59,10 @@ export async function encryptCredential( await importMasterKey(masterKey, "encrypt"), new TextEncoder().encode(plaintext), ); - return `v1:${encodeBase64Url(nonce)}:${ - encodeBase64Url(new Uint8Array(ciphertext)) - }`; + return `v1:${encodeBase64Url(nonce)}:${encodeBase64Url(new Uint8Array(ciphertext))}`; } -export async function parseCredentials( - text: string, - masterKey: string, -): Promise { +export async function parseCredentials(text: string, masterKey: string): Promise { const value: unknown = parse(text); if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new Error("credentials must be a mapping"); @@ -112,17 +93,12 @@ export async function parseCredentials( } const { type, model, token } = profile; if (type !== "claude" && type !== "codex" && type !== "opencode") { - throw new Error( - `${at}.type: expected \"claude\", \"codex\", or \"opencode\"`, - ); + throw new Error(`${at}.type: expected \"claude\", \"codex\", or \"opencode\"`); } if (model !== undefined && (typeof model !== "string" || model === "")) { throw new Error(`${at}.model: expected a model name`); } - if ( - (type === "claude" || type === "opencode") && - typeof token !== "string" - ) { + if ((type === "claude" || type === "opencode") && typeof token !== "string") { throw new Error(`${at}.token: expected an encrypted credential`); } if (token !== undefined && typeof token !== "string") { diff --git a/src/credentials_test.ts b/src/credentials_test.ts index 62a4241..8d4cd44 100644 --- a/src/credentials_test.ts +++ b/src/credentials_test.ts @@ -1,9 +1,5 @@ import { assertEquals, assertRejects } from "jsr:@std/assert@1"; -import { - decryptCredential, - encryptCredential, - parseCredentials, -} from "./credentials.ts"; +import { decryptCredential, encryptCredential, parseCredentials } from "./credentials.ts"; const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"; @@ -11,17 +7,11 @@ const NONCE = Uint8Array.from({ length: 12 }, (_, index) => index + 32); const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA"; Deno.test("decrypts a known AES-256-GCM credential", async () => { - assertEquals( - await decryptCredential(ENCRYPTED, MASTER_KEY), - "secret-token", - ); + assertEquals(await decryptCredential(ENCRYPTED, MASTER_KEY), "secret-token"); }); Deno.test("encrypts a credential with a fresh supplied nonce", async () => { - assertEquals( - await encryptCredential("secret-token", MASTER_KEY, NONCE), - ENCRYPTED, - ); + assertEquals(await encryptCredential("secret-token", MASTER_KEY, NONCE), ENCRYPTED); }); Deno.test("rejects a credential encrypted by another master key", async () => { @@ -30,11 +20,7 @@ Deno.test("rejects a credential encrypted by another master key", async () => { Deno.test("rejects a credential with a nonstandard nonce", async () => { await assertRejects( - () => - decryptCredential( - "v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", - MASTER_KEY, - ), + () => decryptCredential("v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", MASTER_KEY), Error, "nonce must be 12 bytes", ); @@ -75,17 +61,9 @@ profiles: }); Deno.test("rejects malformed central credentials", async () => { + await assertRejects(() => parseCredentials("profiles: []", MASTER_KEY), Error, "reviewer-token"); await assertRejects( - () => parseCredentials("profiles: []", MASTER_KEY), - Error, - "reviewer-token", - ); - await assertRejects( - () => - parseCredentials( - `reviewer-token: ${ENCRYPTED}\nprofiles:\n - type: claude`, - MASTER_KEY, - ), + () => parseCredentials(`reviewer-token: ${ENCRYPTED}\nprofiles:\n - type: claude`, MASTER_KEY), Error, "profiles[0].token", ); diff --git a/src/isolation.ts b/src/isolation.ts index e1c83f2..d8e357d 100644 --- a/src/isolation.ts +++ b/src/isolation.ts @@ -14,10 +14,7 @@ export function agentCommand( }); } -export function isRunnableProfileProcess( - status: string, - uid: number, -): boolean { +export function isRunnableProfileProcess(status: string, uid: number): boolean { const owner = status.match(/^Uid:\s+(\d+)/m)?.[1]; const state = status.match(/^State:\s+(\w)/m)?.[1]; return owner !== undefined && Number(owner) === uid && state !== "Z"; @@ -27,9 +24,7 @@ async function profileProcessIds(uid: number): Promise { const ids: number[] = []; for await (const entry of Deno.readDir("/proc")) { if (!entry.isDirectory || !/^\d+$/.test(entry.name)) continue; - const status = await Deno.readTextFile(`/proc/${entry.name}/status`).catch( - () => null, - ); + const status = await Deno.readTextFile(`/proc/${entry.name}/status`).catch(() => null); if (status !== null && isRunnableProfileProcess(status, uid)) { ids.push(Number(entry.name)); } @@ -53,15 +48,9 @@ export async function killProfileProcesses(uid: number): Promise { throw new Error(`profile UID ${uid} still owns processes after cleanup`); } -export async function readProfileFile( - profile: RuntimeProfile, - path: string, -): Promise { +export async function readProfileFile(profile: RuntimeProfile, path: string): Promise { const stat = await Deno.lstat(path); - if ( - !stat.isFile || stat.isSymlink || stat.uid !== profile.uid || - (stat.mode! & 0o077) !== 0 - ) { + if (!stat.isFile || stat.isSymlink || stat.uid !== profile.uid || (stat.mode! & 0o077) !== 0) { throw new Error(`${path}: expected a profile-owned regular private file`); } return await Deno.readTextFile(path); diff --git a/src/isolation_test.ts b/src/isolation_test.ts index 0826856..f39d95a 100644 --- a/src/isolation_test.ts +++ b/src/isolation_test.ts @@ -69,12 +69,12 @@ Deno.test({ const own = await agentCommand(selected, "/bin/cat", [`${homeA}/token`], { cwd: "/", }).output(); - const sibling = await agentCommand(selected, "/bin/cat", [ - `${homeB}/token`, - ], { cwd: "/" }).output(); - const reviewer = await agentCommand(selected, "/bin/cat", [ - `${root}/reviewer`, - ], { cwd: "/" }).output(); + const sibling = await agentCommand(selected, "/bin/cat", [`${homeB}/token`], { + cwd: "/", + }).output(); + const reviewer = await agentCommand(selected, "/bin/cat", [`${root}/reviewer`], { + cwd: "/", + }).output(); assertEquals(new TextDecoder().decode(own.stdout), "own"); assertEquals(own.success, true); @@ -92,25 +92,22 @@ Deno.test({ async fn() { if (Deno.uid() !== 0) return; const selected = profile(20_010, "/tmp"); - const launched = await agentCommand(selected, "/bin/sh", [ - "-c", - "sleep 60 >/dev/null 2>&1 & echo $!", - ], { cwd: "/" }).output(); + const launched = await agentCommand( + selected, + "/bin/sh", + ["-c", "sleep 60 >/dev/null 2>&1 & echo $!"], + { cwd: "/" }, + ).output(); const pid = Number(new TextDecoder().decode(launched.stdout).trim()); assertEquals(Number.isInteger(pid), true); await killProfileProcesses(selected.uid); - const status = await Deno.readTextFile(`/proc/${pid}/status`).catch( - (error) => { - if (error instanceof Deno.errors.NotFound) return null; - throw error; - }, - ); - assertEquals( - status === null || !isRunnableProfileProcess(status, selected.uid), - true, - ); + const status = await Deno.readTextFile(`/proc/${pid}/status`).catch((error) => { + if (error instanceof Deno.errors.NotFound) return null; + throw error; + }); + assertEquals(status === null || !isRunnableProfileProcess(status, selected.uid), true); }, }); @@ -120,9 +117,7 @@ Deno.test({ async fn() { if (Deno.uid() !== 0) return; const selected = profile(20_020, "/tmp"); - const result = await agentCommand(selected, "/usr/bin/cat", [ - `/proc/${Deno.pid}/environ`, - ], { + const result = await agentCommand(selected, "/usr/bin/cat", [`/proc/${Deno.pid}/environ`], { cwd: "/", env: { PARENT_SECRET: "must-not-cross-boundary" }, clearEnv: true, diff --git a/src/opencode.ts b/src/opencode.ts index 82074f7..2f9ae24 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -1,15 +1,7 @@ import { AgentAuthError } from "./profiles.ts"; export function openCodeArgs(model: string, prompt: string): string[] { - return [ - "run", - "--format", - "json", - "--auto", - "--model", - model, - prompt, - ]; + return ["run", "--format", "json", "--auto", "--model", model, prompt]; } type ChunkWriter = (chunk: Uint8Array) => void | Promise; @@ -20,10 +12,7 @@ export async function collectOpenCodeOutput( writeStdout: ChunkWriter, writeStderr: ChunkWriter, ): Promise<{ stdout: string; stderr: string }> { - const collect = async ( - stream: ReadableStream, - write: ChunkWriter, - ) => { + const collect = async (stream: ReadableStream, write: ChunkWriter) => { const chunks: Uint8Array[] = []; for await (const chunk of stream) { chunks.push(chunk); @@ -68,11 +57,12 @@ export function openCodeError(output: string): Error | undefined { 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); + failure = + statusCode === 401 || statusCode === 403 + ? new AgentAuthError("opencode sign-in failed", { + cause: new Error(detail), + }) + : new Error(detail); } return failure; } @@ -89,8 +79,7 @@ export function parseOpenCodeEvents(output: string): string { if (typeof text === "string" && text.trim() !== "") result = text; } if (result === undefined) { - throw openCodeError(output) ?? - new Error("OpenCode produced no text output"); + 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 3b9750d..2bfc5c7 100644 --- a/src/opencode_test.ts +++ b/src/opencode_test.ts @@ -1,9 +1,4 @@ -import { - assertEquals, - assertInstanceOf, - assertRejects, - assertThrows, -} from "jsr:@std/assert@1"; +import { assertEquals, assertInstanceOf, assertRejects, assertThrows } from "jsr:@std/assert@1"; import { collectOpenCodeOutput, openCodeArgs, @@ -13,28 +8,27 @@ import { import { AgentAuthError } from "./profiles.ts"; Deno.test("builds a non-interactive auto-approved OpenCode command", () => { - assertEquals( - openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this"), - [ - "run", - "--format", - "json", - "--auto", - "--model", - "openrouter/qwen/qwen3.8-27b", - "Review this", - ], - ); + assertEquals(openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this"), [ + "run", + "--format", + "json", + "--auto", + "--model", + "openrouter/qwen/qwen3.8-27b", + "Review this", + ]); }); Deno.test("returns the final visible OpenCode text event", () => { assertEquals( - parseOpenCodeEvents([ - JSON.stringify({ type: "step_start", part: { type: "step-start" } }), - JSON.stringify({ type: "text", part: { type: "text", text: "draft" } }), - JSON.stringify({ type: "text", part: { type: "text", text: "done" } }), - JSON.stringify({ type: "step_finish", part: { type: "step-finish" } }), - ].join("\n")), + parseOpenCodeEvents( + [ + JSON.stringify({ type: "step_start", part: { type: "step-start" } }), + JSON.stringify({ type: "text", part: { type: "text", text: "draft" } }), + JSON.stringify({ type: "text", part: { type: "text", text: "done" } }), + JSON.stringify({ type: "step_finish", part: { type: "step-finish" } }), + ].join("\n"), + ), "done", ); }); @@ -113,8 +107,5 @@ Deno.test("surfaces an OpenCode error event instead of empty output", () => { Error, "API error 429", ); - assertEquals( - openCodeError('{"type":"text","part":{"text":"ok"}}'), - undefined, - ); + assertEquals(openCodeError('{"type":"text","part":{"text":"ok"}}'), undefined); }); diff --git a/src/post.ts b/src/post.ts index ef176da..1718bdf 100644 --- a/src/post.ts +++ b/src/post.ts @@ -1,15 +1,6 @@ import { join } from "jsr:@std/path@1"; -import { - readRuntimeManifest, - type RuntimeManifest, - type RuntimeOutcome, -} from "./runtime.ts"; -import { - failureFooter, - type FailureReason, - type ProfileFailure, - withProfile, -} from "./profiles.ts"; +import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome } from "./runtime.ts"; +import { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "./profiles.ts"; export type PostContext = { apiUrl: string; @@ -75,27 +66,30 @@ async function readOutcome(path: string): Promise { 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, - }] + const { type, model, reason } = (entry ?? {}) as Record; + 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" || + outcome.ok !== true || + typeof outcome.body !== "string" || typeof outcome.reviewPath !== "string" || typeof outcome.anchorsPath !== "string" || - typeof outcome.profile !== "object" || outcome.profile === null + typeof outcome.profile !== "object" || + outcome.profile === null ) { throw new Error("invalid agent outcome"); } @@ -120,9 +114,7 @@ async function readAgentArtifact( path: string, name: string, ): Promise { - const profile = manifest.profiles.find((entry) => - join(entry.home, name) === path - ); + const profile = manifest.profiles.find((entry) => join(entry.home, name) === path); if (profile === undefined) throw new Error(`${name}: outside profile home`); let stat: Deno.FileInfo; try { @@ -131,10 +123,7 @@ async function readAgentArtifact( if (error instanceof Deno.errors.NotFound) return null; throw error; } - if ( - !stat.isFile || stat.isSymlink || stat.uid !== profile.uid || - (stat.mode! & 0o077) !== 0 - ) { + if (!stat.isFile || stat.isSymlink || stat.uid !== profile.uid || (stat.mode! & 0o077) !== 0) { throw new Error(`${name}: expected a profile-owned regular private file`); } const text = await Deno.readTextFile(path); @@ -148,8 +137,7 @@ export async function postRuntime( ): Promise { const manifest = await readRuntimeManifest(manifestPath); try { - const reviewerToken = (await Deno.readTextFile(manifest.reviewerFile)) - .trim(); + const reviewerToken = (await Deno.readTextFile(manifest.reviewerFile)).trim(); const request = async (path: string, body: unknown): Promise => { let response: Response; try { @@ -166,16 +154,13 @@ export async function postRuntime( throw new GiteaRequestError(`${path}: ${detail}`, { cause: error }); } if (!response.ok) { - throw new GiteaRequestError( - `${path}: ${response.status} ${await response.text()}`, - ); + throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`); } }; const postComment = (body: string) => - request( - `repos/${context.repository}/issues/${context.issueIndex}/comments`, - { body: stripAnsi(body) }, - ); + request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, { + body: stripAnsi(body), + }); let outcome: RuntimeOutcome; try { @@ -190,11 +175,7 @@ export async function postRuntime( } try { - const review = await readAgentArtifact( - manifest, - outcome.reviewPath, - "review.md", - ); + const review = await readAgentArtifact(manifest, outcome.reviewPath, "review.md"); if (review === null) { if (context.eventName === "pull_request") { throw new Error("agent did not write a pull request review"); @@ -205,31 +186,21 @@ export async function postRuntime( const [verdict, ...rest] = review.split("\n"); const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "馃挰"]; - const anchorText = await readAgentArtifact( - manifest, - outcome.anchorsPath, - "anchors.json", - ); - const anchors = anchorText === null - ? [] - : parseAnchors(anchorText, outcome.anchorsPath); + const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json"); + const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath); const postReview = (entries: Anchor[]) => - request( - `repos/${context.repository}/pulls/${context.issueIndex}/reviews`, - { - body: stripAnsi(withProfile( - [`${mark} ${verdict.trim()}`, ...rest].join("\n"), - outcome.profile, - )), - event, - comments: entries.map(({ path, line, side, body }) => ({ - path, - body: stripAnsi(withProfile(body, outcome.profile)), - new_position: side === "new" ? line : 0, - old_position: side === "old" ? line : 0, - })), - }, - ); + request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, { + body: stripAnsi( + withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile), + ), + event, + comments: entries.map(({ path, line, side, body }) => ({ + path, + body: stripAnsi(withProfile(body, outcome.profile)), + new_position: side === "new" ? line : 0, + old_position: side === "old" ? line : 0, + })), + }); await postReview(anchors).catch(async (error: Error) => { if (anchors.length === 0) throw error; console.error(`inline comments rejected: ${error.message}`); @@ -237,17 +208,13 @@ export async function postRuntime( }); } catch (error) { if (error instanceof GiteaRequestError) throw error; - await postComment( - failureComment([{ ...outcome.profile, reason: "failed" }]), - ); + await postComment(failureComment([{ ...outcome.profile, reason: "failed" }])); throw error; } } finally { - await Deno.remove(manifest.runtimeDir, { recursive: true }).catch( - (error) => { - if (!(error instanceof Deno.errors.NotFound)) throw error; - }, - ); + await Deno.remove(manifest.runtimeDir, { recursive: true }).catch((error) => { + if (!(error instanceof Deno.errors.NotFound)) throw error; + }); } } diff --git a/src/post_test.ts b/src/post_test.ts index 4931ff6..ce908cf 100644 --- a/src/post_test.ts +++ b/src/post_test.ts @@ -28,11 +28,11 @@ async function fixture( await Deno.writeTextFile(reviewerFile, "reviewer-secret"); const outcome: RuntimeOutcome = source.ok ? { - ...source, - profile: { type: "codex", model: "gpt-test" }, - reviewPath: `${profileHome}/review.md`, - anchorsPath: `${profileHome}/anchors.json`, - } + ...source, + profile: { type: "codex", model: "gpt-test" }, + reviewPath: `${profileHome}/review.md`, + anchorsPath: `${profileHome}/anchors.json`, + } : source; await Deno.writeTextFile(outcomeFile, JSON.stringify(outcome)); if (outcome.ok) { @@ -53,15 +53,17 @@ async function fixture( manifestPath, reviewerFile, outcomeFile, - profiles: [{ - index: 0, - type: "claude", - model: "test", - uid: Deno.uid()!, - gid: Deno.gid()!, - home: profileHome, - tokenFile: null, - }], + profiles: [ + { + index: 0, + type: "claude", + model: "test", + uid: Deno.uid()!, + gid: Deno.gid()!, + home: profileHome, + tokenFile: null, + }, + ], }; await Deno.writeTextFile(manifestPath, JSON.stringify(manifest)); return { @@ -93,10 +95,7 @@ Deno.test("posts a successful issue result and removes runtime credentials", asy }); assertEquals(requests.length, 1); - assertEquals( - requests[0].url, - `${context.apiUrl}/repos/owner/repo/issues/7/comments`, - ); + assertEquals(requests[0].url, `${context.apiUrl}/repos/owner/repo/issues/7/comments`); assertEquals(JSON.parse(String(requests[0].init?.body)), { body: "done\n\n> Profile: `codex` 路 `gpt-test`", }); @@ -108,22 +107,22 @@ Deno.test("posts a successful issue result and removes runtime credentials", asy }); Deno.test("retries a rejected inline review without anchors", async () => { - const result = await fixture( - { ok: true, body: "ignored final response" }, - "Approved\nReady", - [{ path: "run.ts", line: 1, side: "new", body: "Finding" }], - ); + const result = await fixture({ ok: true, body: "ignored final response" }, "Approved\nReady", [ + { path: "run.ts", line: 1, side: "new", body: "Finding" }, + ]); const bodies: unknown[] = []; - await postRuntime(result.manifestPath, { - ...context, - eventName: "pull_request", - }, (_url, init) => { - bodies.push(JSON.parse(String(init?.body))); - return Promise.resolve( - new Response(null, { status: bodies.length === 1 ? 422 : 200 }), - ); - }); + await postRuntime( + result.manifestPath, + { + ...context, + eventName: "pull_request", + }, + (_url, init) => { + bodies.push(JSON.parse(String(init?.body))); + return Promise.resolve(new Response(null, { status: bodies.length === 1 ? 422 : 200 })); + }, + ); assertEquals((bodies[0] as { comments: unknown[] }).comments.length, 1); assertEquals( @@ -159,11 +158,14 @@ Deno.test("reports each tried profile and keeps the action failed", async () => "agent phase failed", ); - assertEquals(bodies, [{ - body: "AI bot failed.\n\n> Tried: `codex` 路 `gpt-test` (sign-in failed), " + - "`claude` 路 `claude-test` (usage exhausted), " + - "`opencode` 路 `openrouter/test` (failed)", - }]); + assertEquals(bodies, [ + { + 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)); }); @@ -193,10 +195,11 @@ Deno.test("drops failure entries that are not known labels", async () => { "agent phase failed", ); - assertEquals(bodies, [{ - body: - "AI bot failed.\n\n> Tried: `claude` 路 `claude-test` (sign-in 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 () => { @@ -223,10 +226,8 @@ Deno.test("removes runtime credentials when posting fails", async () => { await assertRejects( () => - postRuntime( - result.manifestPath, - context, - () => Promise.resolve(new Response("failed", { status: 500 })), + postRuntime(result.manifestPath, context, () => + Promise.resolve(new Response("failed", { status: 500 })), ), Error, "500", @@ -278,19 +279,25 @@ Deno.test("reports an attributed failure when a pull request review is missing", await assertRejects( () => - postRuntime(result.manifestPath, { - ...context, - eventName: "pull_request", - }, (_url, init) => { - bodies.push(JSON.parse(String(init?.body))); - return Promise.resolve(new Response(null, { status: 201 })); - }), + postRuntime( + result.manifestPath, + { + ...context, + eventName: "pull_request", + }, + (_url, init) => { + bodies.push(JSON.parse(String(init?.body))); + return Promise.resolve(new Response(null, { status: 201 })); + }, + ), Error, "did not write", ); - assertEquals(bodies, [{ - body: "AI bot failed.\n\n> Tried: `codex` 路 `gpt-test` (failed)", - }]); + assertEquals(bodies, [ + { + body: "AI bot failed.\n\n> Tried: `codex` 路 `gpt-test` (failed)", + }, + ]); assert(await missing(result.runtimeDir)); }); diff --git a/src/prepare.ts b/src/prepare.ts index beb8459..224f378 100644 --- a/src/prepare.ts +++ b/src/prepare.ts @@ -12,14 +12,10 @@ const manifest = await prepareRuntime( env("BOT_AGENTS_MASTER_KEY"), ); try { - await Deno.writeTextFile( - env("GITHUB_OUTPUT"), - `manifest=${manifest.manifestPath}\n`, - { append: true }, - ); + await Deno.writeTextFile(env("GITHUB_OUTPUT"), `manifest=${manifest.manifestPath}\n`, { + append: true, + }); } catch (error) { - await Deno.remove(manifest.runtimeDir, { recursive: true }).catch(() => - undefined - ); + await Deno.remove(manifest.runtimeDir, { recursive: true }).catch(() => undefined); throw error; } diff --git a/src/profiles.ts b/src/profiles.ts index c202f90..95c5abe 100644 --- a/src/profiles.ts +++ b/src/profiles.ts @@ -4,16 +4,11 @@ export type BotProfile = { token?: string; }; -export function profileFooter( - profile: Pick, -): string { +export function profileFooter(profile: Pick): string { return `> Profile: \`${profile.type}\` 路 \`${profile.model}\``; } -export function withProfile( - body: string, - profile: Pick, -): string { +export function withProfile(body: string, profile: Pick): string { return `${body}\n\n${profileFooter(profile)}`; } @@ -27,8 +22,9 @@ export type ProfileFailure = Pick & { 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)); + 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 { @@ -38,19 +34,12 @@ function failureReason(error: unknown): FailureReason { export function failureFooter(failures: ProfileFailure[]): string { if (failures.length === 0) return "> Profile: none"; - return `> Tried: ${ - failures - .map(({ type, model, reason }) => - `\`${type}\` 路 \`${model}\` (${reason})` - ) - .join(", ") - }`; + return `> Tried: ${failures + .map(({ type, model, reason }) => `\`${type}\` 路 \`${model}\` (${reason})`) + .join(", ")}`; } -export async function runProfiles< - T, - P extends Pick, ->( +export async function runProfiles>( profiles: P[], attempt: (profile: P) => Promise, failures: ProfileFailure[] = [], @@ -68,38 +57,34 @@ export async function runProfiles< throw new Error("profiles must contain at least one profile"); } -export function sanitizedEnvironment( - environment: Record, -): Record { +export function sanitizedEnvironment(environment: Record): Record { const result: Record = {}; - for ( - const name of [ - "PATH", - "HOME", - "LANG", - "LANGUAGE", - "LC_ALL", - "TERM", - "COLORTERM", - "CI", - "TMPDIR", - "GITEA_TOKEN", - "GITEA_API_URL", - "GITEA_REPOSITORY", - "GITHUB_WORKSPACE", - "HTTP_PROXY", - "HTTPS_PROXY", - "ALL_PROXY", - "NO_PROXY", - "http_proxy", - "https_proxy", - "all_proxy", - "no_proxy", - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "NODE_EXTRA_CA_CERTS", - ] - ) { + for (const name of [ + "PATH", + "HOME", + "LANG", + "LANGUAGE", + "LC_ALL", + "TERM", + "COLORTERM", + "CI", + "TMPDIR", + "GITEA_TOKEN", + "GITEA_API_URL", + "GITEA_REPOSITORY", + "GITHUB_WORKSPACE", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "NODE_EXTRA_CA_CERTS", + ]) { if (environment[name] !== undefined) result[name] = environment[name]; } return result; diff --git a/src/profiles_test.ts b/src/profiles_test.ts index cd1d85e..58170b2 100644 --- a/src/profiles_test.ts +++ b/src/profiles_test.ts @@ -55,19 +55,18 @@ Deno.test("recognizes Claude's weekly limit message", async () => { Deno.test("recognizes provider API exhaustion status", async () => { const results = await Promise.all( - ["API Error: 402 Insufficient funds", "HTTP error: 429"] - .map((message) => - runProfiles( - [ - { type: "claude", model: "claude-sonnet-5", token: "first" }, - { type: "codex", model: "gpt-5.6-terra" }, - ], - (profile) => { - if (profile.type === "claude") throw new Error(message); - return Promise.resolve("fallback"); - }, - ) + ["API Error: 402 Insufficient funds", "HTTP error: 429"].map((message) => + runProfiles( + [ + { type: "claude", model: "claude-sonnet-5", token: "first" }, + { type: "codex", model: "gpt-5.6-terra" }, + ], + (profile) => { + if (profile.type === "claude") throw new Error(message); + return Promise.resolve("fallback"); + }, ), + ), ); assertEquals(results, ["fallback", "fallback"]); @@ -120,10 +119,10 @@ Deno.test("throws the last sign-in failure when every profile fails", async () = AgentAuthError, "claude sign-in failed", ); - assertEquals(failures.map(({ reason }) => reason), [ - "sign-in failed", - "sign-in failed", - ]); + assertEquals( + failures.map(({ reason }) => reason), + ["sign-in failed", "sign-in failed"], + ); }); Deno.test("preserves an ordinary agent failure", async () => { @@ -191,14 +190,11 @@ Deno.test("exposes only the selected profile credential to the agent", () => { tokenFile: "/runtime/profile-0/token", }; - assertEquals( - agentEnvironment(base, profile, "selected"), - { - PATH: "/usr/bin", - HOME: "/runtime/profile-0", - CLAUDE_CODE_OAUTH_TOKEN: "selected", - }, - ); + assertEquals(agentEnvironment(base, profile, "selected"), { + PATH: "/usr/bin", + HOME: "/runtime/profile-0", + CLAUDE_CODE_OAUTH_TOKEN: "selected", + }); assertEquals(sanitizedEnvironment(base), { PATH: "/usr/bin", HOME: "/home/bot", diff --git a/src/run.ts b/src/run.ts index fade1eb..f85f54e 100644 --- a/src/run.ts +++ b/src/run.ts @@ -10,11 +10,7 @@ import { retryInvalidToken, stageCodexAuth, } from "./auth.ts"; -import { - agentCommand, - killProfileProcesses, - readProfileFile, -} from "./isolation.ts"; +import { agentCommand, killProfileProcesses, readProfileFile } from "./isolation.ts"; import { AgentAuthError, agentEnvironment, @@ -29,11 +25,7 @@ import { openCodeError, parseOpenCodeEvents, } from "./opencode.ts"; -import { - readRuntimeManifest, - type RuntimeOutcome, - type RuntimeProfile, -} from "./runtime.ts"; +import { readRuntimeManifest, type RuntimeOutcome, type RuntimeProfile } from "./runtime.ts"; type GiteaUser = { login: string; email: string }; type GiteaComment = { user: GiteaUser; created_at: string; body: string }; @@ -92,17 +84,16 @@ async function runRoot(command: string, args: string[]): Promise { async function configureGitAuthor(): Promise { const user: GiteaUser = await (await gitea("user")).json(); - for (const [key, value] of [["name", user.login], ["email", user.email]]) { + for (const [key, value] of [ + ["name", user.login], + ["email", user.email], + ]) { await runRoot("git", ["config", `user.${key}`, value]); } } -async function readProfileToken( - profile: RuntimeProfile, -): Promise { - return profile.tokenFile === null - ? undefined - : await Deno.readTextFile(profile.tokenFile); +async function readProfileToken(profile: RuntimeProfile): Promise { + return profile.tokenFile === null ? undefined : await Deno.readTextFile(profile.tokenFile); } async function runAgentCommand( @@ -119,9 +110,7 @@ async function runAgentCommand( if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`); } -async function prepareProfile( - profile: RuntimeProfile, -): Promise { +async function prepareProfile(profile: RuntimeProfile): Promise { await runAgentCommand(profile, "git", [ "config", "--global", @@ -144,15 +133,13 @@ async function renderPrompt( ): Promise<{ prompt: string; reviewPath: string; anchorsPath: string }> { const reviewPath = await makeProfileFile(profile, "review.md"); const anchorsPath = await makeProfileFile(profile, "anchors.json"); - const comments: GiteaComment[] = await (await gitea( - `repos/${REPO}/issues/${INDEX}/comments?limit=100`, - )).json(); + const comments: GiteaComment[] = await ( + await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`) + ).json(); const values: Record = { COMMENT: env("COMMENT"), ISSUE_COMMENTS: comments - .map((comment) => - `## ${comment.user.login} at ${comment.created_at}\n\n${comment.body}\n` - ) + .map((comment) => `## ${comment.user.login} at ${comment.created_at}\n\n${comment.body}\n`) .join("\n"), CODE_RULES: await (await gitea(RULES_PATH)).text(), EVENT_NAME: EVENT, @@ -162,50 +149,46 @@ async function renderPrompt( REVIEW_PATH: reviewPath, ANCHORS_PATH: anchorsPath, }; - const template = await Deno.readTextFile( - new URL("../prompt.md", import.meta.url), - ); + const template = await Deno.readTextFile(new URL("../prompt.md", import.meta.url)); return { - prompt: template.replace( - /\$\{(\w+)\}/g, - (match, name) => values[name] ?? match, - ), + prompt: template.replace(/\$\{(\w+)\}/g, (match, name) => values[name] ?? match), reviewPath, anchorsPath, }; } -async function runClaude( - profile: RuntimeProfile, - token: string, - prompt: string, -): Promise { - const claude = agentCommand(profile, "claude", [ - "--print", - "--dangerously-skip-permissions", - "--model", - profile.model, - "--output-format", - "stream-json", - "--verbose", - prompt, - ], { - cwd: WORKSPACE, - env: { - ...agentEnvironment(PROCESS_ENV, profile, token), - IS_SANDBOX: "1", +async function runClaude(profile: RuntimeProfile, token: string, prompt: string): Promise { + const claude = agentCommand( + profile, + "claude", + [ + "--print", + "--dangerously-skip-permissions", + "--model", + profile.model, + "--output-format", + "stream-json", + "--verbose", + prompt, + ], + { + cwd: WORKSPACE, + env: { + ...agentEnvironment(PROCESS_ENV, profile, token), + IS_SANDBOX: "1", + }, + clearEnv: true, + stdout: "piped", + stderr: "piped", }, - clearEnv: true, - stdout: "piped", - stderr: "piped", - }).spawn(); + ).spawn(); let result: | { - is_error?: boolean; - result?: string; - subtype: string; - api_error_status?: number | null; - } + is_error?: boolean; + result?: string; + subtype: string; + api_error_status?: number | null; + } | undefined; let error = ""; const copyError = claude.stderr.pipeThrough(new TextDecoderStream()).pipeTo( @@ -230,18 +213,14 @@ async function runClaude( } const [status] = await Promise.all([claude.status, copyError]); if (result?.is_error) { - const failure = 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( - error.trim() || `claude ended with ${result?.subtype ?? "no result"}`, - ); + throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`); } if (!status.success) { throw new Error(error.trim() || `claude exited with ${status.code}`); @@ -262,9 +241,9 @@ async function codexDeviceLogin( }).spawn(); let shown = ""; const collect = (stream: ReadableStream): Promise => - stream.pipeThrough(new TextDecoderStream()).pipeTo( - new WritableStream({ write: (chunk) => void (shown += chunk) }), - ); + stream + .pipeThrough(new TextDecoderStream()) + .pipeTo(new WritableStream({ write: (chunk) => void (shown += chunk) })); const drained = Promise.all([collect(login.stdout), collect(login.stderr)]); const status = login.status; await new Promise((resolve) => setTimeout(resolve, 3000)); @@ -299,19 +278,13 @@ async function codexApiLogin( const writer = login.stdin.getWriter(); await writer.write(new TextEncoder().encode(`${token}\n`)); await writer.close(); - const [status, error] = await Promise.all([ - login.status, - new Response(login.stderr).text(), - ]); + const [status, error] = await Promise.all([login.status, new Response(login.stderr).text()]); if (!status.success) { throw new Error(error.trim() || `codex login exited with ${status.code}`); } } -async function makeProfileFile( - profile: RuntimeProfile, - name: string, -): Promise { +async function makeProfileFile(profile: RuntimeProfile, name: string): Promise { const path = `${profile.home}/${name}`; await Deno.writeTextFile(path, "", { mode: 0o600 }); await Deno.chmod(path, 0o600); @@ -324,17 +297,12 @@ async function runCodex( prompt: string, authentication: CodexAuthentication, ): Promise { - const token = authentication.kind === "api-key" - ? authentication.token - : undefined; + const token = authentication.kind === "api-key" ? authentication.token : undefined; const environment = agentEnvironment(PROCESS_ENV, profile, token); if (authentication.kind === "api-key") { await codexApiLogin(profile, environment, authentication.token); } else { - const loggedIn = await agentCommand(profile, "codex", [ - "login", - "status", - ], { + const loggedIn = await agentCommand(profile, "codex", ["login", "status"], { cwd: WORKSPACE, env: environment, clearEnv: true, @@ -343,21 +311,26 @@ async function runCodex( } const outputPath = await makeProfileFile(profile, "result.md"); const attempt = async () => { - const codex = agentCommand(profile, "codex", [ - "exec", - "--model", - profile.model, - "--dangerously-bypass-approvals-and-sandbox", - "--output-last-message", - outputPath, - prompt, - ], { - cwd: WORKSPACE, - env: environment, - clearEnv: true, - stdout: "inherit", - stderr: "piped", - }).spawn(); + const codex = agentCommand( + profile, + "codex", + [ + "exec", + "--model", + profile.model, + "--dangerously-bypass-approvals-and-sandbox", + "--output-last-message", + outputPath, + prompt, + ], + { + cwd: WORKSPACE, + env: environment, + clearEnv: true, + stdout: "inherit", + stderr: "piped", + }, + ).spawn(); const decoder = new TextDecoder(); let error = ""; for await (const chunk of codex.stderr) { @@ -368,16 +341,15 @@ async function runCodex( // 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() - : await retryInvalidToken(attempt, async () => { - await runAgentCommand(profile, "codex", ["logout"]); - await codexDeviceLogin(profile, environment); - }); + const result = + authentication.kind === "api-key" + ? await attempt() + : await retryInvalidToken(attempt, async () => { + await runAgentCommand(profile, "codex", ["logout"]); + await codexDeviceLogin(profile, environment); + }); if (!result.status.success) { - const failure = new Error( - result.error.trim() || `codex exited with ${result.status.code}`, - ); + 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 }); } @@ -391,18 +363,13 @@ async function runOpenCode( token: string, prompt: string, ): Promise { - const process = agentCommand( - profile, - "opencode", - openCodeArgs(profile.model, prompt), - { - cwd: WORKSPACE, - env: agentEnvironment(PROCESS_ENV, profile, token), - clearEnv: true, - stdout: "piped", - stderr: "piped", - }, - ).spawn(); + const process = agentCommand(profile, "opencode", openCodeArgs(profile.model, prompt), { + cwd: WORKSPACE, + env: agentEnvironment(PROCESS_ENV, profile, token), + clearEnv: true, + stdout: "piped", + stderr: "piped", + }).spawn(); const output = collectOpenCodeOutput( process.stdout, process.stderr, @@ -416,17 +383,16 @@ async function runOpenCode( const [status, streams] = await Promise.all([process.status, output]); const error = streams.stderr.trim(); if (!status.success) { - throw openCodeError(streams.stdout) ?? - new Error(error || `opencode exited with ${status.code}`); + throw ( + openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${status.code}`) + ); } return parseOpenCodeEvents(streams.stdout); } async function runProfile(profile: RuntimeProfile): Promise { const token = await readProfileToken(profile); - const authentication = profile.type === "codex" - ? codexAuthentication(token) - : null; + const authentication = profile.type === "codex" ? codexAuthentication(token) : null; let stagedCodexAuth: string | null = null; let body: string | undefined; let resultPath: string | null = null; @@ -434,12 +400,7 @@ async function runProfile(profile: RuntimeProfile): Promise { let anchorsPath: string | undefined; try { if (authentication?.kind === "persistent") { - stagedCodexAuth = await stageCodexAuth( - CODEX_AUTH, - profile.home, - profile.uid, - profile.gid, - ); + stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid); } await prepareProfile(profile); const rendered = await renderPrompt(profile); @@ -459,9 +420,7 @@ async function runProfile(profile: RuntimeProfile): Promise { } } if (resultPath !== null) body = await readProfileFile(profile, resultPath); - if ( - body === undefined || reviewPath === undefined || anchorsPath === undefined - ) { + if (body === undefined || reviewPath === undefined || anchorsPath === undefined) { throw new Error("agent attempt produced no result"); } return { @@ -484,7 +443,7 @@ try { await configureGitAuthor(); await writeOutcome({ ok: true, - ...await runProfiles(MANIFEST.profiles, runProfile, failures), + ...(await runProfiles(MANIFEST.profiles, runProfile, failures)), }); } catch (error) { console.error(error); diff --git a/src/runtime.ts b/src/runtime.ts index 7ced165..8d11a8f 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -22,20 +22,15 @@ export type RuntimeManifest = { export type RuntimeOutcome = | { - ok: true; - body: string; - reviewPath: string; - anchorsPath: string; - profile: Pick; - } + ok: true; + body: string; + reviewPath: string; + anchorsPath: string; + profile: Pick; + } | { ok: false; failures: ProfileFailure[] }; -async function writePrivate( - path: string, - value: string, - uid: number, - gid: number, -): Promise { +async function writePrivate(path: string, value: string, uid: number, gid: number): Promise { await Deno.writeTextFile(path, value, { mode: 0o600 }); await Deno.chmod(path, 0o600); await Deno.chown(path, uid, gid); @@ -97,8 +92,6 @@ export async function prepareRuntime( } } -export async function readRuntimeManifest( - path: string, -): Promise { +export async function readRuntimeManifest(path: string): Promise { return JSON.parse(await Deno.readTextFile(path)); } diff --git a/src/runtime_test.ts b/src/runtime_test.ts index 330f57b..e4a643b 100644 --- a/src/runtime_test.ts +++ b/src/runtime_test.ts @@ -21,14 +21,8 @@ Deno.test({ const manifest = await prepareRuntime(action, root, MASTER_KEY); const serialized = await Deno.readTextFile(manifest.manifestPath); assert(!serialized.includes("secret-token")); - assertEquals( - await Deno.readTextFile(manifest.reviewerFile), - "secret-token", - ); - assertEquals( - (await Deno.stat(manifest.reviewerFile)).mode! & 0o777, - 0o600, - ); + assertEquals(await Deno.readTextFile(manifest.reviewerFile), "secret-token"); + assertEquals((await Deno.stat(manifest.reviewerFile)).mode! & 0o777, 0o600); assertEquals((await Deno.stat(manifest.reviewerFile)).uid, 0); const [claude, codex] = manifest.profiles; diff --git a/src/workflow_test.ts b/src/workflow_test.ts index db4581a..1f3ce92 100644 --- a/src/workflow_test.ts +++ b/src/workflow_test.ts @@ -4,10 +4,7 @@ import { parse } from "jsr:@std/yaml@1"; type Mapping = Record; const mapping = (value: unknown, at: string): Mapping => { - assert( - typeof value === "object" && value !== null && !Array.isArray(value), - at, - ); + assert(typeof value === "object" && value !== null && !Array.isArray(value), at); return value as Mapping; }; @@ -25,7 +22,7 @@ async function yamlFiles(directory = "."): Promise { for await (const entry of Deno.readDir(directory)) { if (entry.name === ".git" || entry.name === ".superpowers") continue; const path = directory === "." ? entry.name : `${directory}/${entry.name}`; - if (entry.isDirectory) result.push(...await yamlFiles(path)); + if (entry.isDirectory) result.push(...(await yamlFiles(path))); if (entry.isFile && /\.ya?ml$/.test(entry.name)) result.push(path); } return result; @@ -52,20 +49,14 @@ Deno.test("the action exposes only the master key and scopes it to preparation", const post = steps.find((step) => step.id === "post"); const source = steps.find((step) => step.id === "source"); assert(checkout && source && prepare && agent && post); - assertEquals( - mapping(checkout.with, "checkout.with").token, - "${{ gitea.token }}", - ); + assertEquals(mapping(checkout.with, "checkout.with").token, "${{ gitea.token }}"); assertEquals( mapping(prepare.env, "prepare.env").BOT_AGENTS_MASTER_KEY, "${{ inputs.master-key }}", ); assertEquals(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY"), false); assertEquals(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY"), false); - assertEquals( - mapping(agent.env, "agent.env").GITEA_TOKEN, - "${{ gitea.token }}", - ); + assertEquals(mapping(agent.env, "agent.env").GITEA_TOKEN, "${{ gitea.token }}"); assert(String(post.if).includes("always()")); for (const step of [prepare, agent, post]) { assert(String(step.run).includes("steps.source.outputs.path")); @@ -88,15 +79,13 @@ Deno.test("the source protection script stages immutable action files", async () assert(output.success, new TextDecoder().decode(output.stderr)); const path = (await Deno.readTextFile(outputFile)).trim().slice(5); - for ( - const file of [ - "src/prepare.ts", - "src/run.ts", - "src/post.ts", - "profiles.yml", - "prompt.md", - ] - ) { + for (const file of [ + "src/prepare.ts", + "src/run.ts", + "src/post.ts", + "profiles.yml", + "prompt.md", + ]) { const stat = await Deno.stat(`${path}/${file}`); assert(stat.isFile, file); assertEquals(stat.mode! & 0o022, 0, file); @@ -109,9 +98,7 @@ Deno.test("the source protection script stages immutable action files", async () Deno.test("module-relative runtime assets exist", async () => { const module = new URL("run.ts", import.meta.url); const source = await Deno.readTextFile(module); - for ( - const match of source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/g) - ) { + for (const match of source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/g)) { const asset = new URL(match[1], module); const stat = await Deno.stat(asset); assert(stat.isFile, asset.pathname); @@ -130,8 +117,8 @@ Deno.test("the repository uses its trusted main action", async () => { const steps = sequence(job.steps, "jobs.agent.steps"); const local = steps.findIndex((step) => step.uses === "./"); assertEquals(local, -1); - const trusted = steps.findIndex((step) => - step.uses === "https://git.capsulizers.com/commons/bot-agents@main" + const trusted = steps.findIndex( + (step) => step.uses === "https://git.capsulizers.com/commons/bot-agents@main", ); assert(trusted >= 0); assertEquals( @@ -145,7 +132,7 @@ Deno.test("CI checks every phase and the root isolation suite", async () => { const workflow = await yaml(".gitea/workflows/check.yml"); const job = mapping(mapping(workflow.jobs, "jobs").deno, "jobs.deno"); const commands = sequence(job.steps, "jobs.deno.steps") - .map((step) => typeof step.run === "string" ? step.run : "") + .map((step) => (typeof step.run === "string" ? step.run : "")) .join("\n"); for (const entrypoint of ["src/prepare.ts", "src/run.ts", "src/post.ts"]) { assert(commands.includes(entrypoint), entrypoint); @@ -178,11 +165,10 @@ Deno.test("profile preparation does not receive provider credentials", async () Deno.test("central profiles fall back from Codex to Claude to OpenCode", async () => { const credentials = await yaml("profiles.yml"); const profiles = sequence(credentials.profiles, "profiles"); - assertEquals(profiles.map((profile) => profile.type), [ - "codex", - "claude", - "opencode", - ]); + assertEquals( + profiles.map((profile) => profile.type), + ["codex", "claude", "opencode"], + ); assertEquals(profiles[2].model, "openrouter/qwen/qwen3.8-27b"); assert(String(profiles[2].token).startsWith("v1:")); });