Format the TypeScript sources with Oxfmt

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