Replace NodeJS dict aliases with maps and named field reads

Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
This commit was merged in pull request #24.
This commit is contained in:
Danny Kim
2026-10-20 05:01:49 +00:00
committed by temeddix
parent 32806120b3
commit 063eb09750
42 changed files with 2386 additions and 982 deletions
+98 -64
View File
@@ -1,4 +1,11 @@
import { mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
import {
mkdir,
readFile,
rm,
stat,
symlink,
writeFile,
} from "node:fs/promises";
import { expect, test } from "vite-plus/test";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
@@ -16,14 +23,19 @@ test("retries once after an invalid refresh token", async () => {
let attempts = 0;
let relogins = 0;
const result = await retryInvalidToken(
() =>
Promise.resolve({
async () => {
const resolved = await Promise.resolve({
code: 1,
error: attempts++ === 0 ? "ERROR: invalid_refresh_token" : "still failed",
}),
() => {
relogins++;
return Promise.resolve();
error:
(attempts += 1) === 1
? "ERROR: invalid_refresh_token"
: "still failed",
});
return resolved;
},
async () => {
relogins += 1;
await Promise.resolve();
},
);
@@ -38,16 +50,17 @@ test("does not retry an unrelated failure", async () => {
let attempts = 0;
let relogins = 0;
await retryInvalidToken(
() => {
attempts++;
return Promise.resolve({
async () => {
attempts += 1;
const resolved = await Promise.resolve({
code: 1,
error: "rate limited",
});
return resolved;
},
() => {
relogins++;
return Promise.resolve();
async () => {
relogins += 1;
await Promise.resolve();
},
);
@@ -85,14 +98,16 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => {
let relogins = 0;
let attempts = 0;
const result = await retryInvalidToken(
() =>
Promise.resolve({
code: attempts++ > 0 ? 0 : 1,
async () => {
const resolved = await Promise.resolve({
code: (attempts += 1) > 1 ? 0 : 1,
error: attempts === 1 ? REVOKED_CODEX.join("\n") : "",
}),
() => {
relogins++;
return Promise.resolve();
});
return resolved;
},
async () => {
relogins += 1;
await Promise.resolve();
},
);
@@ -106,15 +121,19 @@ test("recognizes a rejected Claude OAuth token", () => {
expect(
isClaudeAuthFailure({
apiErrorStatus: 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.",
}),
).toBe(true);
expect(
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.",
}),
).toBe(true);
expect(isClaudeAuthFailure({ apiErrorStatus: 429, result: "API Error: 429" })).toBe(false);
expect(
isClaudeAuthFailure({ apiErrorStatus: 429, result: "API Error: 429" }),
).toBe(false);
});
test("selects API-key login only for a configured Codex token", () => {
@@ -133,47 +152,62 @@ const AUTH_TOKENS = {
};
const AUTH = JSON.stringify({ auth_mode: "chatgpt", tokens: AUTH_TOKENS });
test.skipIf(IS_WINDOWS)("stages and persists only a regular private Codex login", async () => {
const root = await makeTempDir();
try {
const canonical = `${root}/persistent/auth.json`;
await mkdir(`${root}/persistent`);
await writeFile(canonical, AUTH, { mode: 0o600 });
const home = `${root}/profile`;
await mkdir(home);
test.skipIf(IS_WINDOWS)(
"stages and persists only a regular private Codex login",
async () => {
const root = await makeTempDir();
try {
const canonical = `${root}/persistent/auth.json`;
await mkdir(`${root}/persistent`);
await writeFile(canonical, AUTH, { mode: 0o600 });
const home = `${root}/profile`;
await mkdir(home);
const staged = await stageCodexAuth(canonical, home, currentUid(), currentGid());
expect(await readFile(staged, "utf8")).toStrictEqual(AUTH);
expect((await stat(staged)).mode & 0o777).toBe(0o600);
const staged = await stageCodexAuth(
canonical,
home,
currentUid(),
currentGid(),
);
expect(await readFile(staged, "utf8")).toStrictEqual(AUTH);
const stagedStat = await stat(staged);
expect(stagedStat.mode & 0o777).toBe(0o600);
const refreshed = JSON.stringify({
auth_mode: "chatgpt",
tokens: { ...AUTH_TOKENS, access_token: "refreshed" },
});
await writeFile(staged, refreshed);
await persistCodexAuth(staged, canonical);
expect(await readFile(canonical, "utf8")).toStrictEqual(refreshed);
expect((await stat(canonical)).mode & 0o777).toBe(0o600);
} finally {
await rm(root, { recursive: true });
}
});
const refreshed = JSON.stringify({
auth_mode: "chatgpt",
tokens: { ...AUTH_TOKENS, access_token: "refreshed" },
});
await writeFile(staged, refreshed);
await persistCodexAuth(staged, canonical);
expect(await readFile(canonical, "utf8")).toStrictEqual(refreshed);
const canonicalStat = await stat(canonical);
expect(canonicalStat.mode & 0o777).toBe(0o600);
} finally {
await rm(root, { recursive: true });
}
},
);
test.skipIf(IS_WINDOWS)("rejects a symlink or malformed Codex login", async () => {
const root = await makeTempDir();
try {
const canonical = `${root}/auth.json`;
const malformed = `${root}/malformed.json`;
const linked = `${root}/linked.json`;
await writeFile(canonical, AUTH, { mode: 0o600 });
await writeFile(malformed, '{"tokens":{}}', { mode: 0o600 });
await symlink(canonical, linked);
test.skipIf(IS_WINDOWS)(
"rejects a symlink or malformed Codex login",
async () => {
const root = await makeTempDir();
try {
const canonical = `${root}/auth.json`;
const malformed = `${root}/malformed.json`;
const linked = `${root}/linked.json`;
await writeFile(canonical, AUTH, { mode: 0o600 });
await writeFile(malformed, '{"tokens":{}}', { mode: 0o600 });
await symlink(canonical, linked);
await expect(() => persistCodexAuth(linked, canonical)).rejects.toThrow("regular file");
await expect(() => persistCodexAuth(malformed, canonical)).rejects.toThrow(
"invalid Codex login",
);
} finally {
await rm(root, { recursive: true });
}
});
await expect(persistCodexAuth(linked, canonical)).rejects.toThrow(
"regular file",
);
await expect(persistCodexAuth(malformed, canonical)).rejects.toThrow(
"invalid Codex login",
);
} finally {
await rm(root, { recursive: true });
}
},
);
+28 -16
View File
@@ -10,9 +10,9 @@ import {
rm,
writeFile,
} from "node:fs/promises";
import { dirname, join } from "node:path";
import nodePath from "node:path";
import { isNotFound, isObject } from "#common";
import { field, isNotFound, isObject } from "#common";
interface CodexApiKey {
kind: "api-key";
@@ -26,7 +26,9 @@ interface CodexPersistent {
export type CodexAuthentication = CodexApiKey | CodexPersistent;
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
@@ -83,20 +85,23 @@ async function readCodexAuth(path: string): Promise<string> {
throw new Error("Codex login must be private");
}
const text = await readFile(path, "utf8");
let value: unknown;
let value: unknown = null;
try {
value = JSON.parse(text);
} catch {
throw new Error("invalid Codex login");
}
const tokens = isObject(value) ? value["tokens"] : undefined;
const authMode = field(value, "auth_mode");
const tokens = field(value, "tokens");
if (
!isObject(value) ||
typeof value["auth_mode"] !== "string" ||
value["auth_mode"] === "" ||
typeof authMode !== "string" ||
authMode === "" ||
!isObject(tokens) ||
!["access_token", "refresh_token", "id_token", "account_id"].every(
(name) => typeof tokens[name] === "string" && tokens[name] !== "",
(name) => {
const token = field(tokens, name);
return typeof token === "string" && token !== "";
},
)
) {
throw new Error("invalid Codex login");
@@ -110,32 +115,39 @@ export async function stageCodexAuth(
uid: number,
gid: number,
): Promise<string> {
const codexHome = join(profileHome, ".codex");
const codexHome = nodePath.join(profileHome, ".codex");
await mkdir(codexHome, { recursive: true, mode: 0o700 });
await chmod(codexHome, 0o700);
await chown(codexHome, uid, gid);
const stagedPath = join(codexHome, "auth.json");
const stagedPath = nodePath.join(codexHome, "auth.json");
try {
await writeFile(stagedPath, await readCodexAuth(canonicalPath), { mode: 0o600 });
await writeFile(stagedPath, await readCodexAuth(canonicalPath), {
mode: 0o600,
});
await chmod(stagedPath, 0o600);
await chown(stagedPath, uid, gid);
} catch (error) {
if (!isNotFound(error)) throw error;
if (!isNotFound(error)) {
throw error;
}
}
return stagedPath;
}
// Creates an empty private file under a fresh name.
async function makeTempFile(directory: string): Promise<string> {
const path = join(directory, `.tmp-${randomUUID()}`);
const path = nodePath.join(directory, `.tmp-${randomUUID()}`);
const handle = await open(path, "wx", 0o600);
await handle.close();
return path;
}
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);
const directory = nodePath.dirname(canonicalPath);
await mkdir(directory, { recursive: true, mode: 0o700 });
const temporary = await makeTempFile(directory);
try {
+5 -1
View File
@@ -8,7 +8,11 @@ export {
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
export type { ClaudeFailureFields, CodexAuthentication, CommandResult } from "./auth.ts";
export type {
ClaudeFailureFields,
CodexAuthentication,
CommandResult,
} from "./auth.ts";
export {
collectOpenCodeOutput,
openCodeArgs,
+13 -6
View File
@@ -10,7 +10,9 @@ import {
} from "./opencode.ts";
test("builds a non-interactive auto-approved OpenCode command", () => {
expect(openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this")).toStrictEqual([
expect(
openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this"),
).toStrictEqual([
"run",
"--format",
"json",
@@ -70,7 +72,10 @@ test("streams OpenCode output while collecting it", async () => {
},
);
expect(result).toStrictEqual({ stdout: "first\nsecond\n", stderr: "warning\n" });
expect(result).toStrictEqual({
stdout: "first\nsecond\n",
stderr: "warning\n",
});
expect(shown).toStrictEqual(["out:first\n", "err:warning\n", "out:second\n"]);
});
@@ -94,12 +99,14 @@ test("reports a rejected OpenRouter key as a sign-in failure", () => {
assert.instanceOf(error, AgentAuthError);
expect(error.message).toBe("opencode sign-in failed");
assert.instanceOf(error.cause, Error);
expect(error.cause.message).toBe("opencode APIError (API error 401): User not found.");
expect(error.cause.message).toBe(
"opencode APIError (API error 401): User not found.",
);
});
test("surfaces an OpenCode error event instead of empty output", () => {
expect(() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded"))).toThrow(
"API error 429",
);
expect(() =>
parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded")),
).toThrow("API error 429");
expect(openCodeError('{"type":"text","part":{"text":"ok"}}')).toBeNull();
});
+25 -15
View File
@@ -1,4 +1,4 @@
import { isObject } from "#common";
import { field, isObject } from "#common";
import { AgentAuthError } from "#profiles";
export function openCodeArgs(model: string, prompt: string): string[] {
@@ -49,20 +49,23 @@ export async function collectOpenCodeOutput(
export function openCodeError(output: string): Error | null {
let failure: Error | null = null;
for (const line of output.split("\n")) {
if (line.trim() === "") continue;
let event: unknown;
if (line.trim() === "") {
continue;
}
let event: unknown = null;
try {
event = JSON.parse(line);
} catch {
continue;
}
if (!isObject(event)) continue;
const { type, error } = event;
if (type !== "error" || !isObject(error)) {
const error = field(event, "error");
if (field(event, "type") !== "error" || !isObject(error)) {
continue;
}
const { name, data } = error;
const { message, statusCode } = isObject(data) ? data : {};
const name = field(error, "name");
const data = field(error, "data");
const message = field(data, "message");
const statusCode = field(data, "statusCode");
const detail = `opencode ${typeof name === "string" ? name : "error"}${
typeof statusCode === "number" ? ` (API error ${statusCode})` : ""
}: ${typeof message === "string" ? message : "unknown error"}`;
@@ -79,16 +82,23 @@ export function openCodeError(output: string): Error | null {
export function parseOpenCodeEvents(output: string): string {
let result: string | null = null;
for (const line of output.split("\n")) {
if (line.trim() === "") continue;
if (line.trim() === "") {
continue;
}
const event: unknown = JSON.parse(line);
if (!isObject(event)) continue;
const { type, part } = event;
if (type !== "text" || !isObject(part)) continue;
const { text } = part;
if (typeof text === "string" && text.trim() !== "") result = text;
const part = field(event, "part");
if (field(event, "type") !== "text" || !isObject(part)) {
continue;
}
const text = field(part, "text");
if (typeof text === "string" && text.trim() !== "") {
result = text;
}
}
if (result === null) {
throw openCodeError(output) ?? new Error("OpenCode produced no text output");
throw (
openCodeError(output) ?? new Error("OpenCode produced no text output")
);
}
return result;
}
+1 -2
View File
@@ -1,4 +1,3 @@
// Helpers every phase shares: error and JSON value checks.
export { isNotFound } from "./errors.ts";
export { isMapping, isObject } from "./json.ts";
export type { JsonObject } from "./json.ts";
export { field, isMapping, isObject } from "./json.ts";
+25
View File
@@ -0,0 +1,25 @@
import { expect, test } from "vite-plus/test";
import { field, isMapping, isObject } from "./json.ts";
test("reads a named field of a parsed object", () => {
const value: unknown = JSON.parse(
'{"login":"bot","user":{"login":"nested"}}',
);
expect(field(value, "login")).toBe("bot");
expect(field(field(value, "user"), "login")).toBe("nested");
expect(field(value, "missing")).toBeUndefined();
});
test("reads no field of a value that is not an object", () => {
for (const value of [null, undefined, "text", 1, true]) {
expect(field(value, "login")).toBeUndefined();
}
});
test("counts arrays as objects but not as mappings", () => {
expect(isObject([])).toBe(true);
expect(isMapping([])).toBe(false);
expect(isMapping({})).toBe(true);
expect(field(["first"], "0")).toBe("first");
});
+12 -5
View File
@@ -1,12 +1,19 @@
// Type guards for values read from JSON, which arrive as `unknown`.
export type JsonObject = NodeJS.Dict<unknown>;
// Checks and field reads for values parsed from JSON, which arrive as
// `unknown`. Callers read the fields they know by name into their own
// declared interfaces.
// Any non-null object, arrays included: reading a missing key yields undefined.
export function isObject(value: unknown): value is JsonObject {
// Any non-null object, arrays included.
export function isObject(value: unknown): value is object {
return typeof value === "object" && value !== null;
}
// A plain mapping, which an array is not.
export function isMapping(value: unknown): value is JsonObject {
export function isMapping(value: unknown): value is object {
return isObject(value) && !Array.isArray(value);
}
// The named field of an object, or undefined for a missing field or a value
// that is not an object.
export function field(value: unknown, name: string): unknown {
return isObject(value) ? Reflect.get(value, name) : undefined;
}
+3 -1
View File
@@ -8,7 +8,9 @@ export { type Fetcher, type PostContext, postRuntime } from "./post.ts";
function required(name: string): string {
const value = process.env[name];
if (value === undefined || value === "") throw new Error(`${name} is not set`);
if (value === undefined || value === "") {
throw new Error(`${name} is not set`);
}
return value;
}
+73 -46
View File
@@ -45,9 +45,15 @@ async function writeArtifacts(
review?: string,
anchors?: unknown,
): Promise<void> {
if (!outcome.ok) return;
if (review !== undefined) await writePrivate(outcome.reviewPath, review);
if (anchors !== undefined) await writePrivate(outcome.anchorsPath, JSON.stringify(anchors));
if (!outcome.ok) {
return;
}
if (review !== undefined) {
await writePrivate(outcome.reviewPath, review);
}
if (anchors !== undefined) {
await writePrivate(outcome.anchorsPath, JSON.stringify(anchors));
}
}
function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest {
@@ -59,7 +65,7 @@ function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest {
profiles: [
{
index: 0,
type: "claude",
type: "CLAUDE",
model: "test",
uid: currentUid(),
gid: currentGid(),
@@ -83,7 +89,7 @@ async function fixture(
const outcome: RuntimeOutcome = source.ok
? {
...source,
profile: { type: "codex", model: "gpt-test" },
profile: { type: "CODEX", model: "gpt-test" },
reviewPath: `${profileHome}/review.md`,
anchorsPath: `${profileHome}/anchors.json`,
}
@@ -106,7 +112,9 @@ const missing = async (path: string): Promise<boolean> => {
await stat(path);
return false;
} catch (error) {
if (isNotFound(error)) return true;
if (isNotFound(error)) {
return true;
}
throw error;
}
};
@@ -118,9 +126,10 @@ function requestBody(init?: RequestInit): unknown {
// A fake Gitea that records each request body and answers with `status`.
function recordingFetch(bodies: unknown[], status = 201): Fetcher {
return (_url, init) => {
return async (_url, init) => {
bodies.push(requestBody(init));
return Promise.resolve(new Response(null, { status }));
const resolved = await Promise.resolve(new Response(null, { status }));
return resolved;
};
}
@@ -128,14 +137,17 @@ test("posts a successful issue result and removes runtime credentials", async ()
const result = await fixture({ ok: true, body: "done" });
const requests: RecordedRequest[] = [];
await postRuntime(result.manifestPath, context, (url, init) => {
await postRuntime(result.manifestPath, context, async (url, init) => {
requests.push({ url, init });
return Promise.resolve(new Response(null, { status: 201 }));
const resolved = await Promise.resolve(new Response(null, { status: 201 }));
return resolved;
});
expect(requests.length).toBe(1);
const [request] = requests;
expect(request?.url).toStrictEqual(`${context.apiUrl}/repos/owner/repo/issues/7/comments`);
expect(request?.url).toStrictEqual(
`${context.apiUrl}/repos/owner/repo/issues/7/comments`,
);
expect(requestBody(request?.init)).toStrictEqual({
body: "done\n\n> Profile: `codex` · `gpt-test`",
});
@@ -147,9 +159,11 @@ test("posts a successful issue result and removes runtime credentials", async ()
});
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(
@@ -158,9 +172,12 @@ test("retries a rejected inline review without anchors", async () => {
...context,
eventName: "pull_request",
},
(_url, init) => {
async (_url, init) => {
bodies.push(requestBody(init));
return Promise.resolve(new Response(null, { status: bodies.length === 1 ? 422 : 200 }));
const resolved = await Promise.resolve(
new Response(null, { status: bodies.length === 1 ? 422 : 200 }),
);
return resolved;
},
);
@@ -178,14 +195,14 @@ test("reports each tried profile and keeps the action failed", async () => {
const result = await fixture({
ok: false,
failures: [
{ type: "codex", model: "gpt-test", reason: "sign-in failed" },
{ type: "claude", model: "claude-test", reason: "usage exhausted" },
{ type: "opencode", model: "openrouter/test", reason: "failed" },
{ type: "CODEX", model: "gpt-test", reason: "SIGN_IN_FAILED" },
{ type: "CLAUDE", model: "claude-test", reason: "USAGE_EXHAUSTED" },
{ type: "OPENCODE", model: "openrouter/test", reason: "FAILED" },
],
});
const bodies: unknown[] = [];
await expect(() =>
await expect(
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
).rejects.toThrow("agent phase failed");
@@ -208,15 +225,15 @@ test("drops failure entries that are not known labels", async () => {
JSON.stringify({
ok: false,
failures: [
{ type: "codex", model: "gpt-test", reason: "token sk-secret leaked" },
{ type: "shell", model: "x", reason: "failed" },
{ type: "claude", model: "claude-test", reason: "sign-in failed" },
{ type: "CODEX", model: "gpt-test", reason: "token sk-secret leaked" },
{ type: "shell", model: "x", reason: "FAILED" },
{ type: "CLAUDE", model: "claude-test", reason: "SIGN_IN_FAILED" },
],
}),
);
const bodies: unknown[] = [];
await expect(() =>
await expect(
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
).rejects.toThrow("agent phase failed");
@@ -232,7 +249,7 @@ test("reports a generic failure when the runner wrote no outcome", async () => {
await rm(result.outcomeFile);
const bodies: unknown[] = [];
await expect(() =>
await expect(
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
).rejects.toThrow("agent phase failed");
@@ -243,10 +260,13 @@ test("reports a generic failure when the runner wrote no outcome", async () => {
test("removes runtime credentials when posting fails", async () => {
const result = await fixture({ ok: true, body: "done" });
await expect(() =>
postRuntime(result.manifestPath, context, () =>
Promise.resolve(new Response("failed", { status: 500 })),
),
await expect(
postRuntime(result.manifestPath, context, async () => {
const resolved = await Promise.resolve(
new Response("failed", { status: 500 }),
);
return resolved;
}),
).rejects.toThrow("500");
assert(await missing(result.runtimeDir));
});
@@ -255,10 +275,11 @@ test("preserves a Gitea transport failure without retrying a comment", async ()
const result = await fixture({ ok: true, body: "done" });
let requests = 0;
await expect(() =>
postRuntime(result.manifestPath, context, () => {
await expect(
postRuntime(result.manifestPath, context, async () => {
requests += 1;
return Promise.reject(new Error(`network failure ${requests}`));
await Promise.resolve();
throw new Error(`network failure ${requests}`);
}),
).rejects.toThrow("network failure 1");
@@ -266,27 +287,33 @@ test("preserves a Gitea transport failure without retrying a comment", async ()
assert(await missing(result.runtimeDir));
});
test.skipIf(IS_WINDOWS)("refuses to follow an agent artifact symlink", async () => {
const result = await fixture({ ok: true, body: "done" });
await symlink(result.reviewerFile, result.reviewPath);
let requests = 0;
test.skipIf(IS_WINDOWS)(
"refuses to follow an agent artifact symlink",
async () => {
const result = await fixture({ ok: true, body: "done" });
await symlink(result.reviewerFile, result.reviewPath);
let requests = 0;
await expect(() =>
postRuntime(result.manifestPath, context, () => {
requests += 1;
return Promise.resolve(new Response(null, { status: 200 }));
}),
).rejects.toThrow("regular private file");
await expect(
postRuntime(result.manifestPath, context, async () => {
requests += 1;
const resolved = await Promise.resolve(
new Response(null, { status: 200 }),
);
return resolved;
}),
).rejects.toThrow("regular private file");
expect(requests).toBe(1);
assert(await missing(result.runtimeDir));
});
expect(requests).toBe(1);
assert(await missing(result.runtimeDir));
},
);
test("reports an attributed failure when a pull request review is missing", async () => {
const result = await fixture({ ok: true, body: "done" });
const bodies: unknown[] = [];
await expect(() =>
await expect(
postRuntime(
result.manifestPath,
{
+149 -75
View File
@@ -1,10 +1,19 @@
import type { Stats } from "node:fs";
import { lstat, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import nodePath from "node:path";
import { isNotFound, isObject } from "#common";
import { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "#profiles";
import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome } from "#runtime";
import { field, isNotFound, isObject } from "#common";
import {
failureFooter,
type FailureReason,
type ProfileFailure,
withProfile,
} from "#profiles";
import {
readRuntimeManifest,
type RuntimeManifest,
type RuntimeOutcome,
} from "#runtime";
export interface PostContext {
apiUrl: string;
@@ -16,45 +25,68 @@ export interface PostContext {
interface Anchor {
path: string;
line: number;
side: "new" | "old";
side: "NEW" | "OLD";
body: string;
}
const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu");
const stripAnsi = (text: string): string => text.replace(ANSI, "");
const VERDICTS: NodeJS.Dict<[event: string, mark: string]> = {
Approved: ["APPROVED", "✅"],
"Changes requested": ["REQUEST_CHANGES", "🛑"],
};
const PROFILE_TYPES: Set<unknown> = new Set(["claude", "codex", "opencode"]);
const FAILURE_REASONS: Set<unknown> = new Set(["sign-in failed", "usage exhausted", "failed"]);
const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value);
const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value);
const VERDICTS = new Map<string, [event: string, mark: string]>([
["Approved", ["APPROVED", "✅"]],
["Changes requested", ["REQUEST_CHANGES", "🛑"]],
]);
const PROFILE_TYPES = new Set<unknown>(["CLAUDE", "CODEX", "OPENCODE"]);
const FAILURE_REASONS = new Set<unknown>([
"SIGN_IN_FAILED",
"USAGE_EXHAUSTED",
"FAILED",
]);
// Anchors written before the UPPER_SNAKE switch spell sides in lowercase.
const OLD_SIDES = new Map<unknown, Anchor["side"]>([
["new", "NEW"],
["old", "OLD"],
]);
const isProfileType = (value: unknown): value is ProfileFailure["type"] =>
PROFILE_TYPES.has(value);
const isFailureReason = (value: unknown): value is FailureReason =>
FAILURE_REASONS.has(value);
const failureComment = (failures: ProfileFailure[]): string =>
`AI bot failed.\n\n${failureFooter(failures)}`;
// The `fetch` subset the reviewer posts through, which tests replace.
export type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
class GiteaRequestError extends Error {}
class GiteaRequestError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "GiteaRequestError";
}
}
function parseAnchors(text: string, sourcePath: string): Anchor[] {
const entries: unknown = JSON.parse(text);
if (!Array.isArray(entries)) throw new TypeError(`${sourcePath}: not an array`);
if (!Array.isArray(entries)) {
throw new TypeError(`${sourcePath}: not an array`);
}
return entries.map((entry: unknown, index) => {
const at = `${sourcePath}[${index}]`;
if (!isObject(entry)) {
throw new TypeError(`${at}: not an object`);
}
const { path, line, side = "new", body } = entry;
const path = field(entry, "path");
const line = field(entry, "line");
const givenSide = field(entry, "side");
const side =
givenSide === undefined ? "NEW" : (OLD_SIDES.get(givenSide) ?? givenSide);
const body = field(entry, "body");
if (typeof path !== "string" || path === "") {
throw new TypeError(`${at}.path: expected a repository path`);
}
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
throw new TypeError(`${at}.line: expected a line number`);
}
if (side !== "new" && side !== "old") {
throw new Error(`${at}.side: expected "new" or "old"`);
if (side !== "NEW" && side !== "OLD") {
throw new Error(`${at}.side: expected "NEW" or "OLD"`);
}
if (typeof body !== "string" || body.trim() === "") {
throw new TypeError(`${at}.body: expected the finding`);
@@ -68,43 +100,44 @@ async function readOutcome(path: string): Promise<RuntimeOutcome> {
if (!isObject(value) || !("ok" in value)) {
throw new Error("invalid agent outcome");
}
const outcome = value;
if (outcome["ok"] === false) {
const ok = field(value, "ok");
if (ok === false) {
// Only known labels reach the comment, never raw agent output.
const failures: unknown[] = Array.isArray(outcome["failures"]) ? outcome["failures"] : [];
const listed = field(value, "failures");
const failures: unknown[] = Array.isArray(listed) ? listed : [];
return {
ok: false,
failures: failures.flatMap((entry: unknown) => {
const { type, model, reason } = isObject(entry) ? entry : {};
return isProfileType(type) && typeof model === "string" && isFailureReason(reason)
const type = field(entry, "type");
const model = field(entry, "model");
const reason = field(entry, "reason");
return isProfileType(type) &&
typeof model === "string" &&
isFailureReason(reason)
? [{ type, model, reason }]
: [];
}),
};
}
const body = field(value, "body");
const reviewPath = field(value, "reviewPath");
const anchorsPath = field(value, "anchorsPath");
const profile = field(value, "profile");
if (
outcome["ok"] !== true ||
typeof outcome["body"] !== "string" ||
typeof outcome["reviewPath"] !== "string" ||
typeof outcome["anchorsPath"] !== "string" ||
!isObject(outcome["profile"])
ok !== true ||
typeof body !== "string" ||
typeof reviewPath !== "string" ||
typeof anchorsPath !== "string" ||
!isObject(profile)
) {
throw new Error("invalid agent outcome");
}
const profile = outcome["profile"];
if (!isProfileType(profile["type"]) || typeof profile["model"] !== "string") {
const type = field(profile, "type");
const model = field(profile, "model");
if (!isProfileType(type) || typeof model !== "string") {
throw new Error("invalid agent outcome");
}
return {
ok: true,
body: outcome["body"],
reviewPath: outcome["reviewPath"],
anchorsPath: outcome["anchorsPath"],
profile: {
type: profile["type"],
model: profile["model"],
},
};
return { ok: true, body, reviewPath, anchorsPath, profile: { type, model } };
}
async function readAgentArtifact(
@@ -112,13 +145,19 @@ async function readAgentArtifact(
path: string,
name: string,
): Promise<string | null> {
const profile = manifest.profiles.find((entry) => join(entry.home, name) === path);
if (profile === undefined) throw new Error(`${name}: outside profile home`);
let stat: Stats;
const profile = manifest.profiles.find(
(entry) => nodePath.join(entry.home, name) === path,
);
if (profile === undefined) {
throw new Error(`${name}: outside profile home`);
}
let stat: Stats | null = null;
try {
stat = await lstat(path);
} catch (error) {
if (isNotFound(error)) return null;
if (isNotFound(error)) {
return null;
}
throw error;
}
if (
@@ -140,9 +179,10 @@ export async function postRuntime(
): Promise<void> {
const manifest = await readRuntimeManifest(manifestPath);
try {
const reviewerToken = (await readFile(manifest.reviewerFile, "utf8")).trim();
const reviewerText = await readFile(manifest.reviewerFile, "utf8");
const reviewerToken = reviewerText.trim();
const request = async (path: string, body: unknown): Promise<void> => {
let response: Response;
let response: Response | null = null;
try {
response = await fetcher(`${context.apiUrl}/${path}`, {
method: "POST",
@@ -157,15 +197,21 @@ 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): Promise<void> =>
request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, {
body: stripAnsi(body),
});
const postComment = async (body: string): Promise<void> => {
await request(
`repos/${context.repository}/issues/${context.issueIndex}/comments`,
{
body: stripAnsi(body),
},
);
};
let outcome: RuntimeOutcome;
let outcome: RuntimeOutcome | null = null;
try {
outcome = await readOutcome(manifest.outcomeFile);
} catch (error) {
@@ -178,7 +224,11 @@ 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");
@@ -188,37 +238,61 @@ 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 postReview = (entries: Anchor[]): Promise<void> =>
request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, {
body: stripAnsi(
withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile),
),
event,
comments: entries.map(({ path, line, side, body }) => ({
path,
body: stripAnsi(withProfile(body, outcome.profile)),
new_position: side === "new" ? line : 0,
old_position: side === "old" ? line : 0,
})),
});
const [event, mark] = VERDICTS.get(verdict.trim()) ?? ["COMMENT", "💬"];
const anchorText = await readAgentArtifact(
manifest,
outcome.anchorsPath,
"anchors.json",
);
const anchors =
anchorText === null
? []
: parseAnchors(anchorText, outcome.anchorsPath);
const postReview = async (entries: Anchor[]): Promise<void> => {
await request(
`repos/${context.repository}/pulls/${context.issueIndex}/reviews`,
{
body: stripAnsi(
withProfile(
[`${mark} ${verdict.trim()}`, ...rest].join("\n"),
outcome.profile,
),
),
event,
comments: entries.map(({ path, line, side, body }) => ({
path,
body: stripAnsi(withProfile(body, outcome.profile)),
new_position: side === "NEW" ? line : 0,
old_position: side === "OLD" ? line : 0,
})),
},
);
};
await postReview(anchors).catch(async (error: unknown) => {
if (anchors.length === 0) throw error;
if (anchors.length === 0) {
throw error;
}
console.error(
`inline comments rejected: ${error instanceof Error ? error.message : String(error)}`,
);
await postReview([]);
});
} catch (error) {
if (error instanceof GiteaRequestError) throw error;
await postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
if (error instanceof GiteaRequestError) {
throw error;
}
await postComment(
failureComment([{ ...outcome.profile, reason: "FAILED" }]),
);
throw error;
}
} finally {
await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => {
if (!isNotFound(error)) throw error;
});
await rm(manifest.runtimeDir, { recursive: true }).catch(
(error: unknown) => {
if (!isNotFound(error)) {
throw error;
}
},
);
}
}
+3 -1
View File
@@ -5,7 +5,9 @@ import { prepareRuntime } from "#runtime";
const env = (name: string): string => {
const value = process.env[name];
if (value === undefined || value === "") throw new Error(`${name} is not set`);
if (value === undefined || value === "") {
throw new Error(`${name} is not set`);
}
return value;
};
+9 -3
View File
@@ -6,8 +6,12 @@ import { isNotFound } from "#common";
import { exitCode } from "./child.ts";
test("reports the exit code", async () => {
expect(await exitCode(spawn("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" }))).toBe(0);
expect(await exitCode(spawn("/bin/sh", ["-c", "exit 3"], { stdio: "ignore" }))).toBe(3);
expect(
await exitCode(spawn("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" })),
).toBe(0);
expect(
await exitCode(spawn("/bin/sh", ["-c", "exit 3"], { stdio: "ignore" })),
).toBe(3);
});
test("reports a signal as 128 plus its number", async () => {
@@ -16,7 +20,9 @@ test("reports a signal as 128 plus its number", async () => {
});
test("resolves on exit while a grandchild still holds the output open", async () => {
const child = spawn("/bin/sh", ["-c", "sleep 2 & exit 5"], { stdio: ["ignore", "pipe", "pipe"] });
const child = spawn("/bin/sh", ["-c", "sleep 2 & exit 5"], {
stdio: ["ignore", "pipe", "pipe"],
});
const started = Date.now();
expect(await exitCode(child)).toBe(5);
expect(Date.now() - started).toBeLessThan(1500);
+4 -1
View File
@@ -23,7 +23,10 @@ export function exitCode(child: ChildProcess): Promise<number> {
}
// Writes a chunk, waiting for the stream to drain when its buffer is full.
export async function echo(stream: Writable, chunk: string | Uint8Array): Promise<void> {
export async function echo(
stream: Writable,
chunk: string | Uint8Array,
): Promise<void> {
if (!stream.write(chunk)) {
await once(stream, "drain");
}
+106 -70
View File
@@ -1,5 +1,13 @@
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import { chmod, chown, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import {
chmod,
chown,
mkdir,
readFile,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import process from "node:process";
import { expect, test } from "vite-plus/test";
@@ -7,11 +15,15 @@ import { isNotFound } from "#common";
import type { RuntimeProfile } from "#runtime";
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "#testing";
import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from "./isolation.ts";
import {
isRunnableProfileProcess,
killProfileProcesses,
readProfileFile,
} from "./isolation.ts";
const profile = (uid: number, home: string): RuntimeProfile => ({
index: uid - 20_000,
type: "claude",
type: "CLAUDE",
model: "test",
uid,
gid: uid,
@@ -25,14 +37,14 @@ function runAs(
selected: RuntimeProfile,
command: string,
args: string[],
env?: NodeJS.ProcessEnv,
env?: ReadonlyMap<string, string>,
): SpawnSyncReturns<string> {
return spawnSync(command, args, {
cwd: "/",
uid: selected.uid,
gid: selected.gid,
encoding: "utf8",
...(env !== undefined && { env }),
...(env !== undefined && { env: Object.fromEntries(env) }),
});
}
@@ -41,72 +53,96 @@ test("zombie process entries are already quiescent", () => {
expect(isRunnableProfileProcess(status, 20_010)).toBe(false);
});
test.skipIf(IS_WINDOWS)("refuses to read a profile result symlink as root", async () => {
const root = await makeTempDir();
try {
const secret = `${root}/reviewer-token`;
const result = `${root}/result.md`;
await writeFile(secret, "reviewer-secret", { mode: 0o600 });
await symlink(secret, result);
test.skipIf(IS_WINDOWS)(
"refuses to read a profile result symlink as root",
async () => {
const root = await makeTempDir();
try {
const secret = `${root}/reviewer-token`;
const result = `${root}/result.md`;
await writeFile(secret, "reviewer-secret", { mode: 0o600 });
await symlink(secret, result);
await expect(() => readProfileFile(profile(currentUid(), root), result)).rejects.toThrow(
"regular private file",
await expect(
readProfileFile(profile(currentUid(), root), result),
).rejects.toThrow("regular private file");
} finally {
await rm(root, { recursive: true });
}
},
);
test.skipIf(!IS_ROOT)(
"a profile process can read only its own private files",
async () => {
const root = await makeTempDir();
await chmod(root, 0o711);
try {
const homeA = `${root}/a`;
const homeB = `${root}/b`;
await mkdir(homeA, { mode: 0o700 });
await mkdir(homeB, { mode: 0o700 });
await chown(homeA, 20_000, 20_000);
await chown(homeB, 20_001, 20_001);
await writeFile(`${homeA}/token`, "own", { mode: 0o600 });
await chown(`${homeA}/token`, 20_000, 20_000);
await writeFile(`${homeB}/token`, "sibling", { mode: 0o600 });
await chown(`${homeB}/token`, 20_001, 20_001);
await writeFile(`${root}/reviewer`, "root", { mode: 0o600 });
const selected = profile(20_000, homeA);
const own = runAs(selected, "/bin/cat", [`${homeA}/token`]);
const sibling = runAs(selected, "/bin/cat", [`${homeB}/token`]);
const reviewer = runAs(selected, "/bin/cat", [`${root}/reviewer`]);
expect(own.stdout).toBe("own");
expect(own.status).toBe(0);
expect(sibling.status).not.toBe(0);
expect(reviewer.status).not.toBe(0);
} finally {
await rm(root, { recursive: true });
}
},
);
test.skipIf(!IS_ROOT)(
"cleanup leaves no runnable daemonized process",
async () => {
const selected = profile(20_010, "/tmp");
const launched = runAs(selected, "/bin/sh", [
"-c",
"sleep 60 >/dev/null 2>&1 & echo $!",
]);
const pid = Number(launched.stdout.trim());
expect(Number.isInteger(pid)).toBe(true);
await killProfileProcesses(selected.uid);
const status = await readFile(`/proc/${pid}/status`, "utf8").catch(
(error: unknown) => {
if (isNotFound(error)) {
return null;
}
throw error;
},
);
} finally {
await rm(root, { recursive: true });
}
});
expect(
status === null || !isRunnableProfileProcess(status, selected.uid),
).toBe(true);
},
);
test.skipIf(!IS_ROOT)("a profile process can read only its own private files", async () => {
const root = await makeTempDir();
await chmod(root, 0o711);
try {
const homeA = `${root}/a`;
const homeB = `${root}/b`;
await mkdir(homeA, { mode: 0o700 });
await mkdir(homeB, { mode: 0o700 });
await chown(homeA, 20_000, 20_000);
await chown(homeB, 20_001, 20_001);
await writeFile(`${homeA}/token`, "own", { mode: 0o600 });
await chown(`${homeA}/token`, 20_000, 20_000);
await writeFile(`${homeB}/token`, "sibling", { mode: 0o600 });
await chown(`${homeB}/token`, 20_001, 20_001);
await writeFile(`${root}/reviewer`, "root", { mode: 0o600 });
test.skipIf(!IS_ROOT)(
"the agent process cannot read its coordinator environment",
() => {
const selected = profile(20_020, "/tmp");
const result = runAs(
selected,
"/usr/bin/cat",
[`/proc/${process.pid}/environ`],
new Map([["PARENT_SECRET", "must-not-cross-boundary"]]),
);
const selected = profile(20_000, homeA);
const own = runAs(selected, "/bin/cat", [`${homeA}/token`]);
const sibling = runAs(selected, "/bin/cat", [`${homeB}/token`]);
const reviewer = runAs(selected, "/bin/cat", [`${root}/reviewer`]);
expect(own.stdout).toBe("own");
expect(own.status).toBe(0);
expect(sibling.status).not.toBe(0);
expect(reviewer.status).not.toBe(0);
} finally {
await rm(root, { recursive: true });
}
});
test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async () => {
const selected = profile(20_010, "/tmp");
const launched = runAs(selected, "/bin/sh", ["-c", "sleep 60 >/dev/null 2>&1 & echo $!"]);
const pid = Number(launched.stdout.trim());
expect(Number.isInteger(pid)).toBe(true);
await killProfileProcesses(selected.uid);
const status = await readFile(`/proc/${pid}/status`, "utf8").catch((error: unknown) => {
if (isNotFound(error)) return null;
throw error;
});
expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true);
});
test.skipIf(!IS_ROOT)("the agent process cannot read its coordinator environment", () => {
const selected = profile(20_020, "/tmp");
const result = runAs(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], {
PARENT_SECRET: "must-not-cross-boundary",
});
expect(result.status).not.toBe(0);
});
expect(result.status).not.toBe(0);
},
);
+27 -8
View File
@@ -6,8 +6,8 @@ import { isNotFound } from "#common";
import type { RuntimeProfile } from "#runtime";
export function isRunnableProfileProcess(status: string, uid: number): boolean {
const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1];
const state = /^State:\s+(\w)/mu.exec(status)?.[1];
const owner = /^Uid:\s+(?<uid>\d+)/mu.exec(status)?.groups?.["uid"];
const state = /^State:\s+(?<state>\w)/mu.exec(status)?.groups?.["state"];
return owner !== undefined && Number(owner) === uid && state !== "Z";
}
@@ -17,35 +17,54 @@ async function profileProcessIds(uid: number): Promise<number[]> {
.filter((entry) => entry.isDirectory() && /^\d+$/u.test(entry.name))
.map((entry) => entry.name);
const statuses = await Promise.all(
pids.map((pid) => readFile(`/proc/${pid}/status`, "utf8").catch(() => null)),
pids.map(async (pid) => {
const status = await readFile(`/proc/${pid}/status`, "utf8").catch(
() => null,
);
return status;
}),
);
return pids
.filter((_pid, index) => {
const status = statuses[index];
return status !== null && status !== undefined && isRunnableProfileProcess(status, uid);
return (
status !== null &&
status !== undefined &&
isRunnableProfileProcess(status, uid)
);
})
.map(Number);
}
// Kills the UID's processes every 10 ms until none is left, for up to 100 rounds.
export async function killProfileProcesses(uid: number, attempt = 0): Promise<void> {
export async function killProfileProcesses(
uid: number,
attempt = 0,
): Promise<void> {
if (attempt === 100) {
throw new Error(`profile UID ${uid} still owns processes after cleanup`);
}
const ids = await profileProcessIds(uid);
if (ids.length === 0) return;
if (ids.length === 0) {
return;
}
for (const pid of ids) {
try {
process.kill(pid, "SIGKILL");
} catch (error) {
if (!isNotFound(error)) throw error;
if (!isNotFound(error)) {
throw error;
}
}
}
await sleep(10);
await killProfileProcesses(uid, attempt + 1);
}
export async function readProfileFile(profile: RuntimeProfile, path: string): Promise<string> {
export async function readProfileFile(
profile: RuntimeProfile,
path: string,
): Promise<string> {
const stat = await lstat(path);
if (
!stat.isFile() ||
+6 -1
View File
@@ -9,4 +9,9 @@ export {
sanitizedEnvironment,
withProfile,
} from "./profiles.ts";
export type { BotProfile, FailureReason, ProfileFailure, ProfileIdentity } from "./profiles.ts";
export type {
BotProfile,
FailureReason,
ProfileFailure,
ProfileIdentity,
} from "./profiles.ts";
+109 -83
View File
@@ -11,42 +11,44 @@ import {
} from "./profiles.ts";
test("formats profile attribution as a Markdown quote", () => {
expect(profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" })).toBe(
"> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`",
);
expect(
profileFooter({ type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" }),
).toBe("> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`");
});
test("falls through only after usage exhaustion", async () => {
const visited: string[] = [];
const result = await runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" },
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "CODEX", model: "gpt-5.6-terra" },
],
(profile) => {
async (profile) => {
visited.push(profile.type);
if (profile.type === "claude") {
if (profile.type === "CLAUDE") {
throw new Error("You've hit your usage limit");
}
return Promise.resolve("done");
const resolved = await Promise.resolve("done");
return resolved;
},
);
expect(result).toBe("done");
expect(visited).toStrictEqual(["claude", "codex"]);
expect(visited).toStrictEqual(["CLAUDE", "CODEX"]);
});
test("recognizes Claude's weekly limit message", async () => {
const result = await runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" },
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "CODEX", model: "gpt-5.6-terra" },
],
(profile) => {
if (profile.type === "claude") {
async (profile) => {
if (profile.type === "CLAUDE") {
throw new Error("You've hit your weekly limit · resets 12am (UTC)");
}
return Promise.resolve("fallback");
const resolved = await Promise.resolve("fallback");
return resolved;
},
);
@@ -55,17 +57,23 @@ test("recognizes Claude's weekly limit message", async () => {
test("recognizes provider API exhaustion status", async () => {
const results = await Promise.all(
["API Error: 402 Insufficient funds", "HTTP error: 429"].map((message) =>
runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" },
],
(profile) => {
if (profile.type === "claude") throw new Error(message);
return Promise.resolve("fallback");
},
),
["API Error: 402 Insufficient funds", "HTTP error: 429"].map(
async (message) => {
const result = await runProfiles(
[
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "CODEX", model: "gpt-5.6-terra" },
],
async (profile) => {
if (profile.type === "CLAUDE") {
throw new Error(message);
}
const resolved = await Promise.resolve("fallback");
return resolved;
},
);
return result;
},
),
);
@@ -76,24 +84,27 @@ test("falls through after a sign-in failure and records each try", async () => {
const failures: ProfileFailure[] = [];
const result = await runProfiles(
[
{ type: "codex", model: "gpt-5.6-terra" },
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" },
{ type: "CODEX", model: "gpt-5.6-terra" },
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" },
],
(profile) => {
if (profile.type === "codex") {
async (profile) => {
if (profile.type === "CODEX") {
throw new AgentAuthError("codex sign-in failed");
}
if (profile.type === "claude") throw new Error("API Error: 429");
return Promise.resolve("fallback");
if (profile.type === "CLAUDE") {
throw new Error("API Error: 429");
}
const resolved = await Promise.resolve("fallback");
return resolved;
},
failures,
);
expect(result).toBe("fallback");
expect(failures).toStrictEqual([
{ type: "codex", model: "gpt-5.6-terra", reason: "sign-in failed" },
{ type: "claude", model: "claude-sonnet-5", reason: "usage exhausted" },
{ type: "CODEX", model: "gpt-5.6-terra", reason: "SIGN_IN_FAILED" },
{ type: "CLAUDE", model: "claude-sonnet-5", reason: "USAGE_EXHAUSTED" },
]);
expect(failureFooter(failures)).toStrictEqual(
"> Tried: `codex` · `gpt-5.6-terra` (sign-in failed), " +
@@ -103,11 +114,11 @@ test("falls through after a sign-in failure and records each try", async () => {
test("throws the last sign-in failure when every profile fails", async () => {
const failures: ProfileFailure[] = [];
await expect(() =>
await expect(
runProfiles(
[
{ type: "codex", model: "gpt-5.6-terra" },
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "CODEX", model: "gpt-5.6-terra" },
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
],
(profile) => {
throw new AgentAuthError(`${profile.type} sign-in failed`);
@@ -116,19 +127,24 @@ test("throws the last sign-in failure when every profile fails", async () => {
),
).rejects.toThrow(
expect.toSatisfy(
(e: unknown) => e instanceof AgentAuthError && e.message.includes("claude sign-in failed"),
(error: unknown) =>
error instanceof AgentAuthError &&
error.message.includes("CLAUDE sign-in failed"),
),
);
expect(failures.map(({ reason }) => reason)).toStrictEqual(["sign-in failed", "sign-in failed"]);
expect(failures.map(({ reason }) => reason)).toStrictEqual([
"SIGN_IN_FAILED",
"SIGN_IN_FAILED",
]);
});
test("preserves an ordinary agent failure", async () => {
const visited: string[] = [];
await expect(() =>
await expect(
runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" },
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "CODEX", model: "gpt-5.6-terra" },
],
(profile) => {
visited.push(profile.type);
@@ -136,16 +152,16 @@ test("preserves an ordinary agent failure", async () => {
},
),
).rejects.toThrow("tests failed");
expect(visited).toStrictEqual(["claude"]);
expect(visited).toStrictEqual(["CLAUDE"]);
});
test("does not mistake incidental quota text for exhaustion", async () => {
const visited: string[] = [];
await expect(() =>
await expect(
runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" },
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "CODEX", model: "gpt-5.6-terra" },
],
(profile) => {
visited.push(profile.type);
@@ -153,27 +169,27 @@ test("does not mistake incidental quota text for exhaustion", async () => {
},
),
).rejects.toThrow("quota parser test failed");
expect(visited).toStrictEqual(["claude"]);
expect(visited).toStrictEqual(["CLAUDE"]);
});
test("exposes only the selected profile credential to the agent", () => {
const base = {
PATH: "/usr/bin",
HOME: "/home/bot",
UNRELATED_SECRET: "must-not-reach-agent",
BOT_AGENTS_MASTER_KEY: "master",
PROFILES: "contains every secret",
REVIEWER_TOKEN: "reviewer",
CLAUDE_CODE_OAUTH_TOKEN: "old-claude",
ANTHROPIC_API_KEY: "old-anthropic-key",
ANTHROPIC_AUTH_TOKEN: "old-anthropic-token",
OPENAI_API_KEY: "old-openai",
CODEX_API_KEY: "old-codex-key",
CODEX_ACCESS_TOKEN: "old-codex-token",
};
const base = new Map([
["PATH", "/usr/bin"],
["HOME", "/home/bot"],
["UNRELATED_SECRET", "must-not-reach-agent"],
["BOT_AGENTS_MASTER_KEY", "master"],
["PROFILES", "contains every secret"],
["REVIEWER_TOKEN", "reviewer"],
["CLAUDE_CODE_OAUTH_TOKEN", "old-claude"],
["ANTHROPIC_API_KEY", "old-anthropic-key"],
["ANTHROPIC_AUTH_TOKEN", "old-anthropic-token"],
["OPENAI_API_KEY", "old-openai"],
["CODEX_API_KEY", "old-codex-key"],
["CODEX_ACCESS_TOKEN", "old-codex-token"],
]);
const profile = {
index: 0,
type: "claude" as const,
type: "CLAUDE" as const,
model: "claude-sonnet-5",
uid: 20_000,
gid: 20_000,
@@ -181,21 +197,25 @@ test("exposes only the selected profile credential to the agent", () => {
tokenFile: "/runtime/profile-0/token",
};
expect(agentEnvironment(base, profile, "selected")).toStrictEqual({
PATH: "/usr/bin",
HOME: "/runtime/profile-0",
CLAUDE_CODE_OAUTH_TOKEN: "selected",
});
expect(sanitizedEnvironment(base)).toStrictEqual({
PATH: "/usr/bin",
HOME: "/home/bot",
});
expect(agentEnvironment(base, profile, "selected")).toStrictEqual(
new Map([
["PATH", "/usr/bin"],
["HOME", "/runtime/profile-0"],
["CLAUDE_CODE_OAUTH_TOKEN", "selected"],
]),
);
expect(sanitizedEnvironment(base)).toStrictEqual(
new Map([
["PATH", "/usr/bin"],
["HOME", "/home/bot"],
]),
);
});
test("uses a private Codex home without inventing an API credential", () => {
const profile = {
index: 1,
type: "codex" as const,
type: "CODEX" as const,
model: "gpt-5.6-terra",
uid: 20_001,
gid: 20_001,
@@ -203,17 +223,19 @@ test("uses a private Codex home without inventing an API credential", () => {
tokenFile: null,
};
expect(agentEnvironment({ PATH: "/bin" }, profile)).toStrictEqual({
PATH: "/bin",
HOME: "/runtime/profile-1",
CODEX_HOME: "/runtime/profile-1/.codex",
});
expect(agentEnvironment(new Map([["PATH", "/bin"]]), profile)).toStrictEqual(
new Map([
["PATH", "/bin"],
["HOME", "/runtime/profile-1"],
["CODEX_HOME", "/runtime/profile-1/.codex"],
]),
);
});
test("exposes an OpenRouter key only to the OpenCode profile", () => {
const profile = {
index: 2,
type: "opencode" as const,
type: "OPENCODE" as const,
model: "openrouter/qwen/qwen3.8-27b",
uid: 20_002,
gid: 20_002,
@@ -221,9 +243,13 @@ test("exposes an OpenRouter key only to the OpenCode profile", () => {
tokenFile: "/runtime/profile-2/token",
};
expect(agentEnvironment({ PATH: "/bin" }, profile, "selected")).toStrictEqual({
PATH: "/bin",
HOME: "/runtime/profile-2",
OPENROUTER_API_KEY: "selected",
});
expect(
agentEnvironment(new Map([["PATH", "/bin"]]), profile, "selected"),
).toStrictEqual(
new Map([
["PATH", "/bin"],
["HOME", "/runtime/profile-2"],
["OPENROUTER_API_KEY", "selected"],
]),
);
});
+92 -47
View File
@@ -1,6 +1,6 @@
// The agent and model a profile runs, which every report names.
export interface ProfileIdentity {
type: "claude" | "codex" | "opencode";
type: "CLAUDE" | "CODEX" | "OPENCODE";
model: string;
}
@@ -8,15 +8,39 @@ export interface BotProfile extends ProfileIdentity {
token?: string;
}
// The lowercase name a profile type reads as in posted comments.
function profileName(type: ProfileIdentity["type"]): string {
switch (type) {
case "CLAUDE":
return "claude";
case "CODEX":
return "codex";
case "OPENCODE":
return "opencode";
}
}
export function profileFooter(profile: ProfileIdentity): string {
return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
return `> Profile: \`${profileName(profile.type)}\` · \`${profile.model}\``;
}
export function withProfile(body: string, profile: ProfileIdentity): string {
return `${body}\n\n${profileFooter(profile)}`;
}
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed";
export type FailureReason = "SIGN_IN_FAILED" | "USAGE_EXHAUSTED" | "FAILED";
// The words a failure reason reads as in logs and posted comments.
function toMessage(reason: FailureReason): string {
switch (reason) {
case "SIGN_IN_FAILED":
return "sign-in failed";
case "USAGE_EXHAUSTED":
return "usage exhausted";
case "FAILED":
return "failed";
}
}
export interface ProfileFailure extends ProfileIdentity {
reason: FailureReason;
@@ -29,7 +53,12 @@ interface ProfileHome {
}
// Raised once an agent's credentials are rejected and any re-login is spent.
export class AgentAuthError extends Error {}
export class AgentAuthError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "AgentAuthError";
}
}
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/iu.test(
@@ -38,50 +67,54 @@ function isUsageExhausted(error: unknown): boolean {
}
function failureReason(error: unknown): FailureReason {
if (error instanceof AgentAuthError) return "sign-in failed";
return isUsageExhausted(error) ? "usage exhausted" : "failed";
if (error instanceof AgentAuthError) {
return "SIGN_IN_FAILED";
}
return isUsageExhausted(error) ? "USAGE_EXHAUSTED" : "FAILED";
}
export function failureFooter(failures: ProfileFailure[]): string {
if (failures.length === 0) return "> Profile: none";
if (failures.length === 0) {
return "> Profile: none";
}
return `> Tried: ${failures
.map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${reason})`)
.map(
({ type, model, reason }) =>
`\`${profileName(type)}\` · \`${model}\` (${toMessage(reason)})`,
)
.join(", ")}`;
}
// Tries the profiles from `index` on, moving on only after a sign-in failure
// or exhausted usage, and records every profile it tried.
async function attemptFrom<T, P extends ProfileIdentity>(
profiles: P[],
attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[],
index: number,
): Promise<T> {
const profile = profiles[index];
if (profile === undefined) {
throw new Error("profiles must contain at least one profile");
}
try {
return await attempt(profile);
} catch (error) {
const reason = failureReason(error);
failures.push({ type: profile.type, model: profile.model, reason });
if (reason === "failed" || index === profiles.length - 1) throw error;
console.error(`${profile.type} ${reason}; trying next profile`);
return attemptFrom(profiles, attempt, failures, index + 1);
}
}
export function runProfiles<T, P extends ProfileIdentity>(
// Tries the profiles in order, moving on only after a sign-in failure or
// exhausted usage, and records every profile it tried.
export async function runProfiles<T, P extends ProfileIdentity>(
profiles: P[],
attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[] = [],
): Promise<T> {
return attemptFrom(profiles, attempt, failures, 0);
for (const [index, profile] of profiles.entries()) {
try {
return await attempt(profile);
} catch (error) {
const reason = failureReason(error);
failures.push({ type: profile.type, model: profile.model, reason });
if (reason === "FAILED" || index === profiles.length - 1) {
throw error;
}
console.error(
`${profile.type} ${toMessage(reason)}; trying next profile`,
);
}
}
throw new Error("profiles must contain at least one profile");
}
export function sanitizedEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const result: NodeJS.ProcessEnv = {};
// Environments are maps from variable name to value, converted to the object
// form only where a child process is spawned.
export function sanitizedEnvironment(
environment: ReadonlyMap<string, string>,
): Map<string, string> {
const result = new Map<string, string>();
for (const name of [
"PATH",
"HOME",
@@ -108,27 +141,39 @@ export function sanitizedEnvironment(environment: NodeJS.ProcessEnv): NodeJS.Pro
"SSL_CERT_DIR",
"NODE_EXTRA_CA_CERTS",
]) {
const value = environment[name];
if (value !== undefined) result[name] = value;
const value = environment.get(name);
if (value !== undefined) {
result.set(name, value);
}
}
return result;
}
// The environment variable each agent CLI reads its credential from.
function tokenVariable(type: ProfileIdentity["type"]): string {
switch (type) {
case "CLAUDE":
return "CLAUDE_CODE_OAUTH_TOKEN";
case "CODEX":
return "OPENAI_API_KEY";
case "OPENCODE":
return "OPENROUTER_API_KEY";
}
}
export function agentEnvironment(
environment: NodeJS.ProcessEnv,
environment: ReadonlyMap<string, string>,
profile: ProfileHome,
token?: string,
): NodeJS.ProcessEnv {
): Map<string, string> {
const result = sanitizedEnvironment(environment);
result["HOME"] = profile.home;
if (profile.type === "codex") result["CODEX_HOME"] = `${profile.home}/.codex`;
result.set("HOME", profile.home);
if (profile.type === "CODEX") {
result.set("CODEX_HOME", `${profile.home}/.codex`);
}
if (token !== undefined && token !== "") {
const tokenName = {
claude: "CLAUDE_CODE_OAUTH_TOKEN",
codex: "OPENAI_API_KEY",
opencode: "OPENROUTER_API_KEY",
}[profile.type];
result[tokenName] = token;
const tokenName = tokenVariable(profile.type);
result.set(tokenName, token);
}
return result;
}
+173 -86
View File
@@ -24,8 +24,13 @@ import {
retryInvalidToken,
stageCodexAuth,
} from "#agents";
import { isObject } from "#common";
import { echo, exitCode, killProfileProcesses, readProfileFile } from "#processes";
import { field, isObject } from "#common";
import {
echo,
exitCode,
killProfileProcesses,
readProfileFile,
} from "#processes";
import {
AgentAuthError,
agentEnvironment,
@@ -35,7 +40,11 @@ import {
sanitizedEnvironment,
withProfile,
} from "#profiles";
import { readRuntimeManifest, type RuntimeOutcome, type RuntimeProfile } from "#runtime";
import {
readRuntimeManifest,
type RuntimeOutcome,
type RuntimeProfile,
} from "#runtime";
interface AttemptResult {
body: string;
@@ -58,15 +67,17 @@ interface RenderedPrompt {
const env = (name: string): string => {
const value = process.env[name];
if (value === undefined) throw new Error(`${name} is not set`);
if (value === undefined) {
throw new Error(`${name} is not set`);
}
return value;
};
function definedEnvironment(): NodeJS.ProcessEnv {
const result: NodeJS.ProcessEnv = {};
function definedEnvironment(): Map<string, string> {
const result = new Map<string, string>();
for (const [name, value] of Object.entries(process.env)) {
if (value !== undefined) {
result[name] = value;
result.set(name, value);
}
}
return result;
@@ -104,21 +115,36 @@ async function postAuthorComment(body: string): Promise<void> {
}
async function runRoot(command: string, args: string[]): Promise<void> {
const code = await exitCode(spawn(command, args, { env: PROCESS_ENV, stdio: "ignore" }));
if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`);
const code = await exitCode(
spawn(command, args, {
env: Object.fromEntries(PROCESS_ENV),
stdio: "ignore",
}),
);
if (code !== 0) {
throw new Error(`${command} ${args[0]} exited with ${code}`);
}
}
async function configureGitAuthor(): Promise<void> {
const user: unknown = await (await gitea("user")).json();
if (!isObject(user) || typeof user["login"] !== "string" || typeof user["email"] !== "string") {
throw new Error("user: unexpected Gitea response");
const userResponse = await gitea("user");
const user: unknown = await userResponse.json();
const login = field(user, "login");
const email = field(user, "email");
if (typeof login !== "string" || typeof email !== "string") {
throw new TypeError("user: unexpected Gitea response");
}
await runRoot("git", ["config", "user.name", user["login"]]);
await runRoot("git", ["config", "user.email", user["email"]]);
await runRoot("git", ["config", "user.name", login]);
await runRoot("git", ["config", "user.email", email]);
}
async function readProfileToken(profile: RuntimeProfile): Promise<string | null> {
const token = profile.tokenFile === null ? null : await readFile(profile.tokenFile, "utf8");
async function readProfileToken(
profile: RuntimeProfile,
): Promise<string | null> {
const token =
profile.tokenFile === null
? null
: await readFile(profile.tokenFile, "utf8");
return token;
}
@@ -130,13 +156,27 @@ async function runAgentCommand(
): Promise<void> {
const child = spawn(command, args, {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
env: Object.fromEntries(agentEnvironment(PROCESS_ENV, profile, token)),
uid: profile.uid,
gid: profile.gid,
stdio: "ignore",
});
const code = await exitCode(child);
if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`);
if (code !== 0) {
throw new Error(`${command} ${args[0]} exited with ${code}`);
}
}
// The agent name the skills installer knows each profile type by.
function skillsAgent(type: RuntimeProfile["type"]): string {
switch (type) {
case "CLAUDE":
return "claude-code";
case "CODEX":
return "codex";
case "OPENCODE":
return "opencode";
}
}
async function prepareProfile(profile: RuntimeProfile): Promise<void> {
@@ -152,50 +192,68 @@ async function prepareProfile(profile: RuntimeProfile): Promise<void> {
...SKILLS_INSTALL.split(" "),
"--global",
"--agent",
profile.type === "claude" ? "claude-code" : profile.type,
skillsAgent(profile.type),
"--yes",
]);
}
// One issue comment as the prompt quotes it.
function formatComment(comment: unknown): string {
const { user, created_at: createdAt, body } = isObject(comment) ? comment : {};
const login = isObject(user) ? user["login"] : undefined;
const login = field(field(comment, "user"), "login");
const createdAt = field(comment, "created_at");
const body = field(comment, "body");
return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`;
}
async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
const reviewPath = await makeProfileFile(profile, "review.md");
const anchorsPath = await makeProfileFile(profile, "anchors.json");
const comments: unknown = await (
await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`)
).json();
const commentsResponse = await gitea(
`repos/${REPO}/issues/${INDEX}/comments?limit=100`,
);
const comments: unknown = await commentsResponse.json();
if (!Array.isArray(comments)) {
throw new TypeError("comments: unexpected Gitea response");
}
const values: NodeJS.ProcessEnv = {
COMMENT: env("COMMENT"),
ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"),
CODE_RULES: await (await gitea(RULES_PATH)).text(),
EVENT_NAME: EVENT,
GITEA_API_URL: API,
GITEA_REPOSITORY: REPO,
ISSUE_INDEX: INDEX,
REVIEW_PATH: reviewPath,
ANCHORS_PATH: anchorsPath,
};
const template = await readFile(new URL("../../prompt.md", import.meta.url), "utf8");
const rulesResponse = await gitea(RULES_PATH);
const codeRules = await rulesResponse.text();
const values = new Map<string, string>([
["COMMENT", env("COMMENT")],
[
"ISSUE_COMMENTS",
comments.map((comment: unknown) => formatComment(comment)).join("\n"),
],
["CODE_RULES", codeRules],
["EVENT_NAME", EVENT],
["GITEA_API_URL", API],
["GITEA_REPOSITORY", REPO],
["ISSUE_INDEX", INDEX],
["REVIEW_PATH", reviewPath],
["ANCHORS_PATH", anchorsPath],
]);
const template = await readFile(
new URL("../../prompt.md", import.meta.url),
"utf8",
);
return {
prompt: template.replaceAll(
/\$\{(\w+)\}/gu,
(match: string, name: string) => values[name] ?? match,
/\$\{(?<name>\w+)\}/gu,
(match: string, ...args: unknown[]) => {
const groups = args.at(-1);
const name = isObject(groups) ? field(groups, "name") : undefined;
return typeof name === "string" ? (values.get(name) ?? match) : match;
},
),
reviewPath,
anchorsPath,
};
}
async function runClaude(profile: RuntimeProfile, token: string, prompt: string): Promise<string> {
async function runClaude(
profile: RuntimeProfile,
token: string,
prompt: string,
): Promise<string> {
const claude = spawn(
"claude",
[
@@ -210,10 +268,9 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
],
{
cwd: WORKSPACE,
env: {
...agentEnvironment(PROCESS_ENV, profile, token),
IS_SANDBOX: "1",
},
env: Object.fromEntries(
agentEnvironment(PROCESS_ENV, profile, token).set("IS_SANDBOX", "1"),
),
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
@@ -230,26 +287,36 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
console.error(part);
}
})();
for await (const line of createInterface({ input: claude.stdout, crlfDelay: Infinity })) {
for await (const line of createInterface({
input: claude.stdout,
crlfDelay: Infinity,
})) {
if (line !== "") {
const event: unknown = JSON.parse(line);
if (isObject(event)) {
const content = isObject(event["message"]) ? event["message"]["content"] : undefined;
const content = field(field(event, "message"), "content");
for (const part of Array.isArray(content) ? content : []) {
if (isObject(part)) {
const text = part["thinking"] ?? part["text"] ?? part["name"];
const text =
field(part, "thinking") ??
field(part, "text") ??
field(part, "name");
if (typeof text === "string" && text !== "") {
console.log(text);
}
}
}
if (event["type"] === "result") {
const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event;
if (field(event, "type") === "result") {
const isError = field(event, "is_error");
const answer = field(event, "result");
const subtype = field(event, "subtype");
const apiStatus = field(event, "api_error_status");
result = {
isError: typeof isError === "boolean" ? isError : undefined,
result: typeof answer === "string" ? answer : undefined,
subtype: String(subtype),
apiErrorStatus: typeof apiStatus === "number" ? apiStatus : undefined,
apiErrorStatus:
typeof apiStatus === "number" ? apiStatus : undefined,
};
}
}
@@ -257,14 +324,18 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
}
const [code] = await Promise.all([status, copyError]);
if (result?.isError === true) {
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 (code !== 0) {
throw new Error(error.trim() || `claude exited with ${code}`);
@@ -272,28 +343,13 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
return result.result;
}
// Polls every 100 ms until the login prints something, failing if it exits first.
async function pollForInstructions(
shownText: () => string,
status: Promise<number>,
): Promise<void> {
if (shownText().trim() !== "") {
return;
}
const exited = await Promise.race([status.then(() => true), sleep(100, false)]);
if (exited) {
throw new AgentAuthError("AI bot login produced no instructions");
}
await pollForInstructions(shownText, status);
}
async function codexDeviceLogin(
profile: RuntimeProfile,
environment: NodeJS.ProcessEnv,
environment: ReadonlyMap<string, string>,
): Promise<void> {
const login = spawn("codex", ["login", "--device-auth"], {
cwd: WORKSPACE,
env: environment,
env: Object.fromEntries(environment),
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
@@ -308,7 +364,15 @@ async function codexDeviceLogin(
};
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
await sleep(3000);
await pollForInstructions(() => shown, status);
while (shown.trim() === "") {
const exited = await Promise.race([
status.then(() => true),
sleep(100, false),
]);
if (exited) {
throw new AgentAuthError("AI bot login produced no instructions");
}
}
await postAuthorComment(withProfile(shown, profile));
const [code] = await Promise.all([status, drained]);
if (code !== 0) {
@@ -318,12 +382,12 @@ async function codexDeviceLogin(
async function codexApiLogin(
profile: RuntimeProfile,
environment: NodeJS.ProcessEnv,
environment: ReadonlyMap<string, string>,
token: string,
): Promise<void> {
const login = spawn("codex", ["login", "--with-api-key"], {
cwd: WORKSPACE,
env: environment,
env: Object.fromEntries(environment),
uid: profile.uid,
gid: profile.gid,
stdio: ["pipe", "inherit", "pipe"],
@@ -337,7 +401,10 @@ async function codexApiLogin(
}
}
async function makeProfileFile(profile: RuntimeProfile, name: string): Promise<string> {
async function makeProfileFile(
profile: RuntimeProfile,
name: string,
): Promise<string> {
const path = `${profile.home}/${name}`;
await writeFile(path, "", { mode: 0o600 });
await chmod(path, 0o600);
@@ -350,19 +417,22 @@ 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 check = spawn("codex", ["login", "status"], {
cwd: WORKSPACE,
env: environment,
env: Object.fromEntries(environment),
uid: profile.uid,
gid: profile.gid,
stdio: "ignore",
});
if ((await exitCode(check)) !== 0) await codexDeviceLogin(profile, environment);
if ((await exitCode(check)) !== 0) {
await codexDeviceLogin(profile, environment);
}
}
const outputPath = await makeProfileFile(profile, "result.md");
const attempt = async (): Promise<CommandResult> => {
@@ -379,7 +449,7 @@ async function runCodex(
],
{
cwd: WORKSPACE,
env: environment,
env: Object.fromEntries(environment),
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "inherit", "pipe"],
@@ -404,7 +474,9 @@ async function runCodex(
await codexDeviceLogin(profile, environment);
});
if (result.code !== 0) {
const failure = new Error(result.error.trim() || `codex exited with ${result.code}`);
const failure = new Error(
result.error.trim() || `codex exited with ${result.code}`,
);
if (isCodexAuthFailure(result.error)) {
throw new AgentAuthError("codex sign-in failed", { cause: failure });
}
@@ -420,7 +492,7 @@ async function runOpenCode(
): Promise<string> {
const child = spawn("opencode", openCodeArgs(profile.model, prompt), {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
env: Object.fromEntries(agentEnvironment(PROCESS_ENV, profile, token)),
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
@@ -429,20 +501,28 @@ async function runOpenCode(
const collected = collectOpenCodeOutput(
child.stdout,
child.stderr,
(chunk) => echo(process.stdout, chunk),
(chunk) => echo(process.stderr, chunk),
async (chunk) => {
await echo(process.stdout, chunk);
},
async (chunk) => {
await echo(process.stderr, chunk);
},
);
const [code, streams] = await Promise.all([status, collected]);
const error = streams.stderr.trim();
if (code !== 0) {
throw openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${code}`);
throw (
openCodeError(streams.stdout) ??
new Error(error || `opencode exited with ${code}`)
);
}
return parseOpenCodeEvents(streams.stdout);
}
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
const token = await readProfileToken(profile);
const authentication = profile.type === "codex" ? codexAuthentication(token ?? undefined) : null;
const authentication =
profile.type === "CODEX" ? codexAuthentication(token ?? undefined) : null;
let stagedCodexAuth: string | null = null;
let body: string | null = null;
let resultPath: string | null = null;
@@ -450,15 +530,20 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
let anchorsPath: string | null = null;
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);
reviewPath = rendered.reviewPath;
anchorsPath = rendered.anchorsPath;
if (profile.type === "claude") {
if (profile.type === "CLAUDE") {
body = await runClaude(profile, token ?? "", rendered.prompt);
} else if (profile.type === "codex") {
} else if (profile.type === "CODEX") {
resultPath = await runCodex(
profile,
rendered.prompt,
@@ -473,7 +558,9 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
await persistCodexAuth(stagedCodexAuth, CODEX_AUTH);
}
}
if (resultPath !== null) body = await readProfileFile(profile, resultPath);
if (resultPath !== null) {
body = await readProfileFile(profile, resultPath);
}
if (body === null || reviewPath === null || anchorsPath === null) {
throw new Error("agent attempt produced no result");
}
+43 -17
View File
@@ -1,10 +1,14 @@
import { expect, test } from "vite-plus/test";
import { decryptCredential, encryptCredential, parseCredentials } from "./credentials.ts";
import {
decryptCredential,
encryptCredential,
parseCredentials,
} from "./credentials.ts";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
const NONCE = Uint8Array.from({ length: 12 }, (_, index) => index + 32);
const NONCE = Uint8Array.from({ length: 12 }, (_value, index) => index + 32);
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
test("decrypts a known AES-256-GCM credential", async () => {
@@ -12,16 +16,21 @@ test("decrypts a known AES-256-GCM credential", async () => {
});
test("encrypts a credential with a fresh supplied nonce", async () => {
expect(await encryptCredential("secret-token", MASTER_KEY, NONCE)).toStrictEqual(ENCRYPTED);
expect(
await encryptCredential("secret-token", MASTER_KEY, NONCE),
).toStrictEqual(ENCRYPTED);
});
test("rejects a credential encrypted by another master key", async () => {
await expect(() => decryptCredential(ENCRYPTED, OTHER_KEY)).rejects.toThrow();
await expect(decryptCredential(ENCRYPTED, OTHER_KEY)).rejects.toThrow();
});
test("rejects a credential with a nonstandard nonce", async () => {
await expect(() =>
decryptCredential("v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", MASTER_KEY),
await expect(
decryptCredential(
"v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA",
MASTER_KEY,
),
).rejects.toThrow("nonce must be 12 bytes");
});
@@ -31,9 +40,9 @@ test("decrypts the central reviewer token and ordered profiles", async () => {
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [
{ type: "codex", model: "gpt-custom" },
{ type: "claude", token: ENCRYPTED },
{ type: "opencode", token: ENCRYPTED },
{ type: "CODEX", model: "gpt-custom" },
{ type: "CLAUDE", token: ENCRYPTED },
{ type: "OPENCODE", token: ENCRYPTED },
],
}),
MASTER_KEY,
@@ -41,14 +50,14 @@ test("decrypts the central reviewer token and ordered profiles", async () => {
).toStrictEqual({
reviewerToken: "secret-token",
profiles: [
{ type: "codex", model: "gpt-custom" },
{ type: "CODEX", model: "gpt-custom" },
{
type: "claude",
type: "CLAUDE",
model: "claude-sonnet-5",
token: "secret-token",
},
{
type: "opencode",
type: "OPENCODE",
model: "openrouter/qwen/qwen3.8-27b",
token: "secret-token",
},
@@ -57,13 +66,30 @@ test("decrypts the central reviewer token and ordered profiles", async () => {
});
test("rejects malformed central credentials", async () => {
await expect(() => parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow(
"reviewer-token",
);
await expect(() =>
await expect(
parseCredentials('{"profiles": []}', MASTER_KEY),
).rejects.toThrow("reviewer-token");
await expect(
parseCredentials(
JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "claude" }] }),
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [{ type: "CLAUDE" }],
}),
MASTER_KEY,
),
).rejects.toThrow("profiles[0].token");
});
test("reads lowercase profile types from older credentials", async () => {
const credentials = await parseCredentials(
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [{ type: "codex" }, { type: "claude", token: ENCRYPTED }],
}),
MASTER_KEY,
);
expect(credentials.profiles.map(({ type }) => type)).toStrictEqual([
"CODEX",
"CLAUDE",
]);
});
+91 -32
View File
@@ -1,20 +1,20 @@
import { webcrypto } from "node:crypto";
import { isMapping } from "#common";
import { field, isMapping } from "#common";
import type { BotProfile } from "#profiles";
interface DefaultModels {
claude: string;
codex: string;
opencode: string;
// The model a profile runs when profiles.json names none.
function defaultModel(type: BotProfile["type"]): string {
switch (type) {
case "CLAUDE":
return "claude-sonnet-5";
case "CODEX":
return "gpt-5.6-terra";
case "OPENCODE":
return "openrouter/qwen/qwen3.8-27b";
}
}
const DEFAULT_MODELS: DefaultModels = {
claude: "claude-sonnet-5",
codex: "gpt-5.6-terra",
opencode: "openrouter/qwen/qwen3.8-27b",
};
export interface BotCredentials {
reviewerToken: string;
profiles: BotProfile[];
@@ -23,31 +23,61 @@ export interface BotCredentials {
function decodeBase64Url(value: string): Uint8Array {
const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
return Uint8Array.from(atob(padded), (character) => character.codePointAt(0) ?? 0);
return Uint8Array.from(
atob(padded),
(character) => character.codePointAt(0) ?? 0,
);
}
function encodeBase64Url(value: Uint8Array): string {
const binary = Array.from(value, (byte) => String.fromCodePoint(byte)).join("");
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
const binary = Array.from(value, (byte) => String.fromCodePoint(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;
function importMasterKey(encoded: string, usage: webcrypto.KeyUsage): Promise<webcrypto.CryptoKey> {
async function importMasterKey(
encoded: string,
usage: webcrypto.KeyUsage,
): Promise<webcrypto.CryptoKey> {
const bytes = decodeBase64Url(encoded);
if (bytes.length !== 32) {
throw new Error("master-key must be 32 bytes encoded as base64url");
}
return webcrypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [usage]);
const key = await webcrypto.subtle.importKey(
"raw",
buffer(bytes),
"AES-GCM",
false,
[usage],
);
return key;
}
export async function decryptCredential(encrypted: string, masterKey: string): Promise<string> {
const [version, encodedNonce = "", encodedCiphertext = "", extra = ""] = encrypted.split(":");
if (version !== "v1" || encodedNonce === "" || encodedCiphertext === "" || extra !== "") {
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>");
}
const nonce = decodeBase64Url(encodedNonce);
if (nonce.length !== 12) throw new Error("credential nonce must be 12 bytes");
if (nonce.length !== 12) {
throw new Error("credential nonce must be 12 bytes");
}
const plaintext = await webcrypto.subtle.decrypt(
{ name: "AES-GCM", iv: buffer(nonce) },
await importMasterKey(masterKey, "decrypt"),
@@ -61,7 +91,9 @@ export async function encryptCredential(
masterKey: string,
nonce: Uint8Array = webcrypto.getRandomValues(new Uint8Array(12)),
): Promise<string> {
if (nonce.length !== 12) throw new Error("AES-GCM nonce must be 12 bytes");
if (nonce.length !== 12) {
throw new Error("AES-GCM nonce must be 12 bytes");
}
const ciphertext = await webcrypto.subtle.encrypt(
{ name: "AES-GCM", iv: buffer(nonce) },
await importMasterKey(masterKey, "encrypt"),
@@ -70,7 +102,22 @@ export async function encryptCredential(
return `v1:${encodeBase64Url(nonce)}:${encodeBase64Url(new Uint8Array(ciphertext))}`;
}
async function parseProfile(entry: unknown, index: number, masterKey: string): Promise<BotProfile> {
// profiles.json files written before the UPPER_SNAKE switch spell types in lowercase.
const OLD_PROFILE_TYPES = new Map<unknown, BotProfile["type"]>([
["claude", "CLAUDE"],
["codex", "CODEX"],
["opencode", "OPENCODE"],
]);
function currentSpelling(type: unknown): unknown {
return OLD_PROFILE_TYPES.get(type) ?? type;
}
async function parseProfile(
entry: unknown,
index: number,
masterKey: string,
): Promise<BotProfile> {
const at = `profiles[${index}]`;
if (!isMapping(entry)) {
throw new TypeError(`${at}: expected a mapping`);
@@ -80,14 +127,16 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P
throw new Error(`${at}.${key}: unknown field`);
}
}
const { type, model, token } = entry;
if (type !== "claude" && type !== "codex" && type !== "opencode") {
throw new Error(`${at}.type: expected "claude", "codex", or "opencode"`);
const type = currentSpelling(field(entry, "type"));
const model = field(entry, "model");
const token = field(entry, "token");
if (type !== "CLAUDE" && type !== "CODEX" && type !== "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") {
@@ -95,14 +144,17 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P
}
return {
type,
model: model ?? DEFAULT_MODELS[type],
model: model ?? defaultModel(type),
...(typeof token === "string" && {
token: await decryptCredential(token, masterKey),
}),
};
}
export async function parseCredentials(text: string, masterKey: string): Promise<BotCredentials> {
export async function parseCredentials(
text: string,
masterKey: string,
): Promise<BotCredentials> {
const value: unknown = JSON.parse(text);
if (!isMapping(value)) {
throw new TypeError("credentials must be a mapping");
@@ -112,7 +164,8 @@ export async function parseCredentials(text: string, masterKey: string): Promise
throw new Error(`${key}: unknown field`);
}
}
const { "reviewer-token": reviewerToken, profiles } = value;
const reviewerToken = field(value, "reviewer-token");
const profiles = field(value, "profiles");
if (typeof reviewerToken !== "string") {
throw new TypeError("reviewer-token: expected an encrypted credential");
}
@@ -120,7 +173,13 @@ export async function parseCredentials(text: string, masterKey: string): Promise
throw new Error("profiles must contain at least one profile");
}
const parsed = await Promise.all(
profiles.map((entry: unknown, index) => parseProfile(entry, index, masterKey)),
profiles.map(async (entry: unknown, index) => {
const profile = await parseProfile(entry, index, masterKey);
return profile;
}),
);
return { reviewerToken: await decryptCredential(reviewerToken, masterKey), profiles: parsed };
return {
reviewerToken: await decryptCredential(reviewerToken, masterKey),
profiles: parsed,
};
}
+102 -74
View File
@@ -1,5 +1,12 @@
import { spawnSync } from "node:child_process";
import { chmod, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import {
chmod,
readdir,
readFile,
rm,
stat,
writeFile,
} from "node:fs/promises";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { assert, expect, test } from "vite-plus/test";
@@ -12,90 +19,111 @@ const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
// Runs `command` as the profile's user, from the root directory.
function runAs(profile: RuntimeProfile, command: string, path: string): boolean {
const result = spawnSync(command, [path], { cwd: "/", uid: profile.uid, gid: profile.gid });
function runAs(
profile: RuntimeProfile,
command: string,
path: string,
): boolean {
const result = spawnSync(command, [path], {
cwd: "/",
uid: profile.uid,
gid: profile.gid,
});
return result.status === 0;
}
async function expectPrivate(path: string, uid: number, mode: number): Promise<void> {
async function expectPrivate(
path: string,
uid: number,
mode: number,
): Promise<void> {
const info = await stat(path);
expect(info.uid).toStrictEqual(uid);
expect(info.mode & 0o777).toBe(mode);
}
test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credentials", async () => {
const root = await makeTempDir();
await chmod(root, 0o711);
const action = await makeTempDir();
try {
await writeFile(
`${action}/profiles.json`,
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [{ type: "claude", token: ENCRYPTED }, { type: "codex" }],
}),
);
test.skipIf(!IS_ROOT)(
"prepares root-only reviewer and isolated profile credentials",
async () => {
const root = await makeTempDir();
await chmod(root, 0o711);
const action = await makeTempDir();
try {
await writeFile(
`${action}/profiles.json`,
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [{ type: "CLAUDE", token: ENCRYPTED }, { type: "CODEX" }],
}),
);
const manifest = await prepareRuntime(action, root, MASTER_KEY);
const serialized = await readFile(manifest.manifestPath, "utf8");
assert(!serialized.includes("secret-token"));
expect(await readFile(manifest.reviewerFile, "utf8")).toBe("secret-token");
await expectPrivate(manifest.reviewerFile, 0, 0o600);
const manifest = await prepareRuntime(action, root, MASTER_KEY);
const serialized = await readFile(manifest.manifestPath, "utf8");
assert(!serialized.includes("secret-token"));
expect(await readFile(manifest.reviewerFile, "utf8")).toBe(
"secret-token",
);
await expectPrivate(manifest.reviewerFile, 0, 0o600);
const [claude, codex] = manifest.profiles;
assert(claude !== undefined && codex !== undefined);
expect(claude.uid).not.toStrictEqual(codex.uid);
await expectPrivate(claude.home, claude.uid, 0o700);
assert(claude.tokenFile !== null);
expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token");
await expectPrivate(claude.tokenFile, claude.uid, 0o600);
expect((await stat(codex.home)).uid).toStrictEqual(codex.uid);
expect(codex.tokenFile).toBe(null);
const [claude, codex] = manifest.profiles;
assert(claude !== undefined && codex !== undefined);
expect(claude.uid).not.toStrictEqual(codex.uid);
await expectPrivate(claude.home, claude.uid, 0o700);
assert(claude.tokenFile !== null);
expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token");
await expectPrivate(claude.tokenFile, claude.uid, 0o600);
const codexHome = await stat(codex.home);
expect(codexHome.uid).toStrictEqual(codex.uid);
expect(codex.tokenFile).toBe(null);
const ownToken = spawnSync("/bin/cat", [claude.tokenFile], {
cwd: "/",
uid: claude.uid,
gid: claude.gid,
encoding: "utf8",
});
expect(ownToken.status).toBe(0);
expect(ownToken.stdout).toBe("secret-token");
expect(runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false);
expect(runAs(claude, "/bin/ls", codex.home)).toBe(false);
} finally {
await rm(root, { recursive: true });
await rm(action, { recursive: true });
}
});
const ownToken = spawnSync("/bin/cat", [claude.tokenFile], {
cwd: "/",
uid: claude.uid,
gid: claude.gid,
encoding: "utf8",
});
expect(ownToken.status).toBe(0);
expect(ownToken.stdout).toBe("secret-token");
expect(runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false);
expect(runAs(claude, "/bin/ls", codex.home)).toBe(false);
} finally {
await rm(root, { recursive: true });
await rm(action, { recursive: true });
}
},
);
test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fails", async () => {
const root = await makeTempDir();
const action = await makeTempDir();
try {
await writeFile(
`${action}/profiles.json`,
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [{ type: "claude", token: ENCRYPTED }],
}),
);
const result = spawnSync(
process.execPath,
[fileURLToPath(new URL("../prepare/index.ts", import.meta.url))],
{
env: {
ACTION_PATH: action,
RUNNER_TEMP: root,
BOT_AGENTS_MASTER_KEY: MASTER_KEY,
GITHUB_OUTPUT: action,
test.skipIf(!IS_ROOT)(
"removes prepared credentials when output publication fails",
async () => {
const root = await makeTempDir();
const action = await makeTempDir();
try {
await writeFile(
`${action}/profiles.json`,
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [{ type: "CLAUDE", token: ENCRYPTED }],
}),
);
const result = spawnSync(
process.execPath,
[fileURLToPath(new URL("../prepare/index.ts", import.meta.url))],
{
env: Object.fromEntries([
["ACTION_PATH", action],
["RUNNER_TEMP", root],
["BOT_AGENTS_MASTER_KEY", MASTER_KEY],
["GITHUB_OUTPUT", action],
]),
},
},
);
);
expect(result.status).not.toBe(0);
expect(await readdir(root)).toStrictEqual([]);
} finally {
await rm(root, { recursive: true });
await rm(action, { recursive: true });
}
});
expect(result.status).not.toBe(0);
expect(await readdir(root)).toStrictEqual([]);
} finally {
await rm(root, { recursive: true });
await rm(action, { recursive: true });
}
},
);
+54 -41
View File
@@ -1,7 +1,15 @@
import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import {
chmod,
chown,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import nodePath from "node:path";
import { isMapping } from "#common";
import { field, isMapping } from "#common";
import type { ProfileFailure, ProfileIdentity } from "#profiles";
import { parseCredentials } from "./credentials.ts";
@@ -39,7 +47,12 @@ export interface RuntimeFailure {
export type RuntimeOutcome = RuntimeSuccess | RuntimeFailure;
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 writeFile(path, value, { mode: 0o600 });
await chmod(path, 0o600);
await chown(path, uid, gid);
@@ -51,49 +64,47 @@ export async function prepareRuntime(
masterKey: string,
): Promise<RuntimeManifest> {
const credentials = await parseCredentials(
await readFile(join(actionPath, "profiles.json"), "utf8"),
await readFile(nodePath.join(actionPath, "profiles.json"), "utf8"),
masterKey,
);
const runtimeDir = await mkdtemp(join(runtimeRoot, "bot-agents-"));
const runtimeDir = await mkdtemp(nodePath.join(runtimeRoot, "bot-agents-"));
try {
await chmod(runtimeDir, 0o711);
const reviewerFile = join(runtimeDir, "reviewer-token");
const reviewerFile = nodePath.join(runtimeDir, "reviewer-token");
await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0);
// One profile at a time, in order, as each claims the next UID.
const profiles = await credentials.profiles.reduce<Promise<RuntimeProfile[]>>(
async (previous, profile, index) => {
const prepared = await previous;
const uid = FIRST_PROFILE_UID + index;
const home = join(runtimeDir, `profile-${index}`);
await mkdir(home, { mode: 0o700 });
await chmod(home, 0o700);
await chown(home, uid, uid);
const tokenFile =
profile.token === undefined || profile.token === "" ? null : join(home, "token");
if (tokenFile !== null && profile.token !== undefined) {
await writePrivate(tokenFile, profile.token, uid, uid);
}
prepared.push({
index,
type: profile.type,
model: profile.model,
uid,
gid: uid,
home,
tokenFile,
});
return prepared;
},
Promise.resolve([]),
);
const profiles: RuntimeProfile[] = [];
for (const [index, profile] of credentials.profiles.entries()) {
const uid = FIRST_PROFILE_UID + index;
const home = nodePath.join(runtimeDir, `profile-${index}`);
await mkdir(home, { mode: 0o700 });
await chmod(home, 0o700);
await chown(home, uid, uid);
const tokenFile =
profile.token === undefined || profile.token === ""
? null
: nodePath.join(home, "token");
if (tokenFile !== null && profile.token !== undefined) {
await writePrivate(tokenFile, profile.token, uid, uid);
}
profiles.push({
index,
type: profile.type,
model: profile.model,
uid,
gid: uid,
home,
tokenFile,
});
}
const manifestPath = join(runtimeDir, "manifest.json");
const manifestPath = nodePath.join(runtimeDir, "manifest.json");
const manifest: RuntimeManifest = {
runtimeDir,
manifestPath,
reviewerFile,
outcomeFile: join(runtimeDir, "outcome.json"),
outcomeFile: nodePath.join(runtimeDir, "outcome.json"),
profiles,
};
await writePrivate(manifestPath, JSON.stringify(manifest), 0, 0);
@@ -107,15 +118,17 @@ export async function prepareRuntime(
function isRuntimeManifest(value: unknown): value is RuntimeManifest {
return (
isMapping(value) &&
typeof value["runtimeDir"] === "string" &&
typeof value["manifestPath"] === "string" &&
typeof value["reviewerFile"] === "string" &&
typeof value["outcomeFile"] === "string" &&
Array.isArray(value["profiles"])
typeof field(value, "runtimeDir") === "string" &&
typeof field(value, "manifestPath") === "string" &&
typeof field(value, "reviewerFile") === "string" &&
typeof field(value, "outcomeFile") === "string" &&
Array.isArray(field(value, "profiles"))
);
}
export async function readRuntimeManifest(path: string): Promise<RuntimeManifest> {
export async function readRuntimeManifest(
path: string,
): Promise<RuntimeManifest> {
const value: unknown = JSON.parse(await readFile(path, "utf8"));
if (!isRuntimeManifest(value)) {
throw new Error(`${path}: invalid runtime manifest`);
+28 -10
View File
@@ -1,12 +1,12 @@
// Test support: the current user's IDs, a fresh temporary directory, and a
// plain command runner. It imports no source module, so the repository-level
// tests in tests/, which reach src/ only through `#testing`, never touch
// internals.
// Test support: the current user's IDs, a fresh temporary directory, the
// inherited environment, and a plain command runner. It imports no source
// module, so the repository-level tests in tests/, which reach src/ only
// through `#testing`, never touch internals.
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import nodePath from "node:path";
import process from "node:process";
import { text } from "node:stream/consumers";
@@ -24,8 +24,9 @@ export const IS_ROOT: boolean = currentUid() === 0;
// Symlinks and POSIX modes, as these tests use them, need a Unix host.
export const IS_WINDOWS: boolean = process.platform === "win32";
export function makeTempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), "bot-agents-test-"));
export async function makeTempDir(): Promise<string> {
const path = await mkdtemp(nodePath.join(tmpdir(), "bot-agents-test-"));
return path;
}
export interface CommandResult {
@@ -33,15 +34,32 @@ export interface CommandResult {
stderr: string;
}
// This process's environment as a map from variable name to value.
export function inheritedEnvironment(): Map<string, string> {
const result = new Map<string, string>();
for (const [name, value] of Object.entries(process.env)) {
if (value !== undefined) {
result.set(name, value);
}
}
return result;
}
// Runs a command with stdin closed and only the given environment, and
// collects its stderr.
export async function runCommand(
command: string,
args: string[],
env: NodeJS.ProcessEnv,
env: ReadonlyMap<string, string>,
): Promise<CommandResult> {
const child = spawn(command, args, { env, stdio: ["ignore", "ignore", "pipe"] });
const [stderr, closed] = await Promise.all([text(child.stderr), once(child, "close")]);
const child = spawn(command, args, {
env: Object.fromEntries(env),
stdio: ["ignore", "ignore", "pipe"],
});
const [stderr, closed] = await Promise.all([
text(child.stderr),
once(child, "close"),
]);
const exitCode: unknown = closed[0];
return { success: exitCode === 0, stderr };
}