Format the TypeScript sources with Oxfmt
This commit is contained in:
+23
-22
@@ -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<T>, relogin: () => Promise<void>): Promise<T> {
|
||||
export async function retryInvalidToken<T extends { status: { success: boolean }; error: string }>(
|
||||
attempt: () => Promise<T>,
|
||||
relogin: () => Promise<void>,
|
||||
): Promise<T> {
|
||||
let result = await attempt();
|
||||
if (!result.status.success && isCodexAuthFailure(result.error)) {
|
||||
await relogin();
|
||||
@@ -59,9 +60,12 @@ async function readCodexAuth(path: string): Promise<string> {
|
||||
const record = value as Record<string, unknown> | null;
|
||||
const tokens = record?.tokens as Record<string, unknown> | 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<void> {
|
||||
export async function persistCodexAuth(stagedPath: string, canonicalPath: string): Promise<void> {
|
||||
const text = await readCodexAuth(stagedPath);
|
||||
const directory = dirname(canonicalPath);
|
||||
await Deno.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
|
||||
+20
-37
@@ -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 }, {
|
||||
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 [
|
||||
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 }, {
|
||||
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 });
|
||||
}
|
||||
|
||||
+11
-35
@@ -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<CryptoKey> {
|
||||
async function importMasterKey(encoded: string, usage: KeyUsage): Promise<CryptoKey> {
|
||||
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<string> {
|
||||
const [version, encodedNonce, encodedCiphertext, extra] = encrypted.split(
|
||||
":",
|
||||
);
|
||||
export async function decryptCredential(encrypted: string, masterKey: string): Promise<string> {
|
||||
const [version, encodedNonce, encodedCiphertext, extra] = encrypted.split(":");
|
||||
if (version !== "v1" || !encodedNonce || !encodedCiphertext || extra) {
|
||||
throw new Error("credential must use v1:<nonce>:<ciphertext>");
|
||||
}
|
||||
@@ -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<BotCredentials> {
|
||||
export async function parseCredentials(text: string, masterKey: string): Promise<BotCredentials> {
|
||||
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") {
|
||||
|
||||
+6
-28
@@ -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",
|
||||
);
|
||||
|
||||
+4
-15
@@ -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<number[]> {
|
||||
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<void> {
|
||||
throw new Error(`profile UID ${uid} still owns processes after cleanup`);
|
||||
}
|
||||
|
||||
export async function readProfileFile(
|
||||
profile: RuntimeProfile,
|
||||
path: string,
|
||||
): Promise<string> {
|
||||
export async function readProfileFile(profile: RuntimeProfile, path: string): Promise<string> {
|
||||
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);
|
||||
|
||||
+16
-21
@@ -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) => {
|
||||
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,
|
||||
);
|
||||
});
|
||||
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,
|
||||
|
||||
+5
-16
@@ -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<void>;
|
||||
@@ -20,10 +12,7 @@ export async function collectOpenCodeOutput(
|
||||
writeStdout: ChunkWriter,
|
||||
writeStderr: ChunkWriter,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const collect = async (
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
write: ChunkWriter,
|
||||
) => {
|
||||
const collect = async (stream: ReadableStream<Uint8Array>, write: ChunkWriter) => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk);
|
||||
@@ -68,7 +57,8 @@ 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
|
||||
failure =
|
||||
statusCode === 401 || statusCode === 403
|
||||
? new AgentAuthError("opencode sign-in failed", {
|
||||
cause: new Error(detail),
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+8
-17
@@ -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,9 +8,7 @@ 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"),
|
||||
[
|
||||
assertEquals(openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this"), [
|
||||
"run",
|
||||
"--format",
|
||||
"json",
|
||||
@@ -23,18 +16,19 @@ Deno.test("builds a non-interactive auto-approved OpenCode command", () => {
|
||||
"--model",
|
||||
"openrouter/qwen/qwen3.8-27b",
|
||||
"Review this",
|
||||
],
|
||||
);
|
||||
]);
|
||||
});
|
||||
|
||||
Deno.test("returns the final visible OpenCode text event", () => {
|
||||
assertEquals(
|
||||
parseOpenCodeEvents([
|
||||
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")),
|
||||
].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);
|
||||
});
|
||||
|
||||
+33
-66
@@ -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<RuntimeOutcome> {
|
||||
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" &&
|
||||
const { type, model, reason } = (entry ?? {}) as Record<string, unknown>;
|
||||
return typeof type === "string" &&
|
||||
PROFILE_TYPES.includes(type) &&
|
||||
typeof model === "string" &&
|
||||
typeof reason === "string" &&
|
||||
FAILURE_REASONS.includes(reason)
|
||||
? [{
|
||||
? [
|
||||
{
|
||||
type: type as ProfileFailure["type"],
|
||||
model,
|
||||
reason: reason as FailureReason,
|
||||
}]
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (
|
||||
outcome.ok !== true || typeof outcome.body !== "string" ||
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<void> => {
|
||||
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,22 +186,13 @@ 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,
|
||||
)),
|
||||
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,
|
||||
@@ -228,8 +200,7 @@ export async function postRuntime(
|
||||
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) => {
|
||||
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch((error) => {
|
||||
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
-32
@@ -53,7 +53,8 @@ async function fixture(
|
||||
manifestPath,
|
||||
reviewerFile,
|
||||
outcomeFile,
|
||||
profiles: [{
|
||||
profiles: [
|
||||
{
|
||||
index: 0,
|
||||
type: "claude",
|
||||
model: "test",
|
||||
@@ -61,7 +62,8 @@ async function fixture(
|
||||
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, {
|
||||
await postRuntime(
|
||||
result.manifestPath,
|
||||
{
|
||||
...context,
|
||||
eventName: "pull_request",
|
||||
}, (_url, init) => {
|
||||
},
|
||||
(_url, init) => {
|
||||
bodies.push(JSON.parse(String(init?.body)));
|
||||
return Promise.resolve(
|
||||
new Response(null, { status: bodies.length === 1 ? 422 : 200 }),
|
||||
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), " +
|
||||
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, {
|
||||
postRuntime(
|
||||
result.manifestPath,
|
||||
{
|
||||
...context,
|
||||
eventName: "pull_request",
|
||||
}, (_url, init) => {
|
||||
},
|
||||
(_url, init) => {
|
||||
bodies.push(JSON.parse(String(init?.body)));
|
||||
return Promise.resolve(new Response(null, { status: 201 }));
|
||||
}),
|
||||
},
|
||||
),
|
||||
Error,
|
||||
"did not write",
|
||||
);
|
||||
|
||||
assertEquals(bodies, [{
|
||||
assertEquals(bodies, [
|
||||
{
|
||||
body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (failed)",
|
||||
}]);
|
||||
},
|
||||
]);
|
||||
assert(await missing(result.runtimeDir));
|
||||
});
|
||||
|
||||
+4
-8
@@ -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;
|
||||
}
|
||||
|
||||
+12
-27
@@ -4,16 +4,11 @@ export type BotProfile = {
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export function profileFooter(
|
||||
profile: Pick<BotProfile, "type" | "model">,
|
||||
): string {
|
||||
export function profileFooter(profile: Pick<BotProfile, "type" | "model">): string {
|
||||
return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
|
||||
}
|
||||
|
||||
export function withProfile(
|
||||
body: string,
|
||||
profile: Pick<BotProfile, "type" | "model">,
|
||||
): string {
|
||||
export function withProfile(body: string, profile: Pick<BotProfile, "type" | "model">): string {
|
||||
return `${body}\n\n${profileFooter(profile)}`;
|
||||
}
|
||||
|
||||
@@ -27,8 +22,9 @@ export type ProfileFailure = Pick<BotProfile, "type" | "model"> & {
|
||||
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<BotProfile, "type" | "model">,
|
||||
>(
|
||||
export async function runProfiles<T, P extends Pick<BotProfile, "type" | "model">>(
|
||||
profiles: P[],
|
||||
attempt: (profile: P) => Promise<T>,
|
||||
failures: ProfileFailure[] = [],
|
||||
@@ -68,12 +57,9 @@ export async function runProfiles<
|
||||
throw new Error("profiles must contain at least one profile");
|
||||
}
|
||||
|
||||
export function sanitizedEnvironment(
|
||||
environment: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
export function sanitizedEnvironment(environment: Record<string, string>): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (
|
||||
const name of [
|
||||
for (const name of [
|
||||
"PATH",
|
||||
"HOME",
|
||||
"LANG",
|
||||
@@ -98,8 +84,7 @@ export function sanitizedEnvironment(
|
||||
"SSL_CERT_FILE",
|
||||
"SSL_CERT_DIR",
|
||||
"NODE_EXTRA_CA_CERTS",
|
||||
]
|
||||
) {
|
||||
]) {
|
||||
if (environment[name] !== undefined) result[name] = environment[name];
|
||||
}
|
||||
return result;
|
||||
|
||||
+8
-12
@@ -55,8 +55,7 @@ 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) =>
|
||||
["API Error: 402 Insufficient funds", "HTTP error: 429"].map((message) =>
|
||||
runProfiles(
|
||||
[
|
||||
{ type: "claude", model: "claude-sonnet-5", token: "first" },
|
||||
@@ -66,7 +65,7 @@ Deno.test("recognizes provider API exhaustion status", async () => {
|
||||
if (profile.type === "claude") throw new Error(message);
|
||||
return Promise.resolve("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"),
|
||||
{
|
||||
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",
|
||||
|
||||
+53
-94
@@ -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<void> {
|
||||
|
||||
async function configureGitAuthor(): Promise<void> {
|
||||
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<string | undefined> {
|
||||
return profile.tokenFile === null
|
||||
? undefined
|
||||
: await Deno.readTextFile(profile.tokenFile);
|
||||
async function readProfileToken(profile: RuntimeProfile): Promise<string | undefined> {
|
||||
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<void> {
|
||||
async function prepareProfile(profile: RuntimeProfile): Promise<void> {
|
||||
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<string, string> = {
|
||||
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,25 +149,19 @@ 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<string> {
|
||||
const claude = agentCommand(profile, "claude", [
|
||||
async function runClaude(profile: RuntimeProfile, token: string, prompt: string): Promise<string> {
|
||||
const claude = agentCommand(
|
||||
profile,
|
||||
"claude",
|
||||
[
|
||||
"--print",
|
||||
"--dangerously-skip-permissions",
|
||||
"--model",
|
||||
@@ -189,7 +170,8 @@ async function runClaude(
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
prompt,
|
||||
], {
|
||||
],
|
||||
{
|
||||
cwd: WORKSPACE,
|
||||
env: {
|
||||
...agentEnvironment(PROCESS_ENV, profile, token),
|
||||
@@ -198,7 +180,8 @@ async function runClaude(
|
||||
clearEnv: true,
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
}).spawn();
|
||||
},
|
||||
).spawn();
|
||||
let result:
|
||||
| {
|
||||
is_error?: boolean;
|
||||
@@ -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<Uint8Array>): Promise<void> =>
|
||||
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<string> {
|
||||
async function makeProfileFile(profile: RuntimeProfile, name: string): Promise<string> {
|
||||
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<string> {
|
||||
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,7 +311,10 @@ async function runCodex(
|
||||
}
|
||||
const outputPath = await makeProfileFile(profile, "result.md");
|
||||
const attempt = async () => {
|
||||
const codex = agentCommand(profile, "codex", [
|
||||
const codex = agentCommand(
|
||||
profile,
|
||||
"codex",
|
||||
[
|
||||
"exec",
|
||||
"--model",
|
||||
profile.model,
|
||||
@@ -351,13 +322,15 @@ async function runCodex(
|
||||
"--output-last-message",
|
||||
outputPath,
|
||||
prompt,
|
||||
], {
|
||||
],
|
||||
{
|
||||
cwd: WORKSPACE,
|
||||
env: environment,
|
||||
clearEnv: true,
|
||||
stdout: "inherit",
|
||||
stderr: "piped",
|
||||
}).spawn();
|
||||
},
|
||||
).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"
|
||||
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<string> {
|
||||
const process = agentCommand(
|
||||
profile,
|
||||
"opencode",
|
||||
openCodeArgs(profile.model, prompt),
|
||||
{
|
||||
const process = agentCommand(profile, "opencode", openCodeArgs(profile.model, prompt), {
|
||||
cwd: WORKSPACE,
|
||||
env: agentEnvironment(PROCESS_ENV, profile, token),
|
||||
clearEnv: true,
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
},
|
||||
).spawn();
|
||||
}).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<AttemptResult> {
|
||||
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<AttemptResult> {
|
||||
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<AttemptResult> {
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
+2
-9
@@ -30,12 +30,7 @@ export type RuntimeOutcome =
|
||||
}
|
||||
| { ok: false; failures: ProfileFailure[] };
|
||||
|
||||
async function writePrivate(
|
||||
path: string,
|
||||
value: string,
|
||||
uid: number,
|
||||
gid: number,
|
||||
): Promise<void> {
|
||||
async function writePrivate(path: string, value: string, uid: number, gid: number): Promise<void> {
|
||||
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<RuntimeManifest> {
|
||||
export async function readRuntimeManifest(path: string): Promise<RuntimeManifest> {
|
||||
return JSON.parse(await Deno.readTextFile(path));
|
||||
}
|
||||
|
||||
+2
-8
@@ -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;
|
||||
|
||||
+14
-28
@@ -4,10 +4,7 @@ import { parse } from "jsr:@std/yaml@1";
|
||||
type Mapping = Record<string, unknown>;
|
||||
|
||||
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<string[]> {
|
||||
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 [
|
||||
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:"));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user