Replace NodeJS dict aliases with maps and named field reads
Agents / agent (pull_request) Has been skipped
Check / check (pull_request) Successful in 49s

This commit is contained in:
Danny Kim
2026-10-19 02:30:54 +09:00
parent 32806120b3
commit c8ffb9cc6f
15 changed files with 275 additions and 196 deletions
+9 -8
View File
@@ -12,7 +12,7 @@ import {
} from "node:fs/promises"; } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { isNotFound, isObject } from "#common"; import { field, isNotFound, isObject } from "#common";
interface CodexApiKey { interface CodexApiKey {
kind: "api-key"; kind: "api-key";
@@ -89,15 +89,16 @@ async function readCodexAuth(path: string): Promise<string> {
} catch { } catch {
throw new Error("invalid Codex login"); 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 ( if (
!isObject(value) || typeof authMode !== "string" ||
typeof value["auth_mode"] !== "string" || authMode === "" ||
value["auth_mode"] === "" ||
!isObject(tokens) || !isObject(tokens) ||
!["access_token", "refresh_token", "id_token", "account_id"].every( !["access_token", "refresh_token", "id_token", "account_id"].every((name) => {
(name) => typeof tokens[name] === "string" && tokens[name] !== "", const token = field(tokens, name);
) return typeof token === "string" && token !== "";
})
) { ) {
throw new Error("invalid Codex login"); throw new Error("invalid Codex login");
} }
+10 -10
View File
@@ -1,4 +1,4 @@
import { isObject } from "#common"; import { field, isObject } from "#common";
import { AgentAuthError } from "#profiles"; import { AgentAuthError } from "#profiles";
export function openCodeArgs(model: string, prompt: string): string[] { export function openCodeArgs(model: string, prompt: string): string[] {
@@ -56,13 +56,14 @@ export function openCodeError(output: string): Error | null {
} catch { } catch {
continue; continue;
} }
if (!isObject(event)) continue; const error = field(event, "error");
const { type, error } = event; if (field(event, "type") !== "error" || !isObject(error)) {
if (type !== "error" || !isObject(error)) {
continue; continue;
} }
const { name, data } = error; const name = field(error, "name");
const { message, statusCode } = isObject(data) ? data : {}; const data = field(error, "data");
const message = field(data, "message");
const statusCode = field(data, "statusCode");
const detail = `opencode ${typeof name === "string" ? name : "error"}${ const detail = `opencode ${typeof name === "string" ? name : "error"}${
typeof statusCode === "number" ? ` (API error ${statusCode})` : "" typeof statusCode === "number" ? ` (API error ${statusCode})` : ""
}: ${typeof message === "string" ? message : "unknown error"}`; }: ${typeof message === "string" ? message : "unknown error"}`;
@@ -81,10 +82,9 @@ export function parseOpenCodeEvents(output: string): string {
for (const line of output.split("\n")) { for (const line of output.split("\n")) {
if (line.trim() === "") continue; if (line.trim() === "") continue;
const event: unknown = JSON.parse(line); const event: unknown = JSON.parse(line);
if (!isObject(event)) continue; const part = field(event, "part");
const { type, part } = event; if (field(event, "type") !== "text" || !isObject(part)) continue;
if (type !== "text" || !isObject(part)) continue; const text = field(part, "text");
const { text } = part;
if (typeof text === "string" && text.trim() !== "") result = text; if (typeof text === "string" && text.trim() !== "") result = text;
} }
if (result === null) { if (result === null) {
+1 -2
View File
@@ -1,4 +1,3 @@
// Helpers every phase shares: error and JSON value checks. // Helpers every phase shares: error and JSON value checks.
export { isNotFound } from "./errors.ts"; export { isNotFound } from "./errors.ts";
export { isMapping, isObject } from "./json.ts"; export { field, isMapping, isObject } from "./json.ts";
export type { JsonObject } from "./json.ts";
+23
View File
@@ -0,0 +1,23 @@
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`. // Checks and field reads for values parsed from JSON, which arrive as
export type JsonObject = NodeJS.Dict<unknown>; // `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. // Any non-null object, arrays included.
export function isObject(value: unknown): value is JsonObject { export function isObject(value: unknown): value is object {
return typeof value === "object" && value !== null; return typeof value === "object" && value !== null;
} }
// A plain mapping, which an array is not. // 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); 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;
}
+31 -28
View File
@@ -2,7 +2,7 @@ import type { Stats } from "node:fs";
import { lstat, readFile, rm } from "node:fs/promises"; import { lstat, readFile, rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { isNotFound, isObject } from "#common"; import { field, isNotFound, isObject } from "#common";
import { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "#profiles"; import { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "#profiles";
import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome } from "#runtime"; import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome } from "#runtime";
@@ -22,10 +22,10 @@ interface Anchor {
const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu"); const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu");
const stripAnsi = (text: string): string => text.replace(ANSI, ""); const stripAnsi = (text: string): string => text.replace(ANSI, "");
const VERDICTS: NodeJS.Dict<[event: string, mark: string]> = { const VERDICTS: Map<string, [event: string, mark: string]> = new Map([
Approved: ["APPROVED", "✅"], ["Approved", ["APPROVED", "✅"]],
"Changes requested": ["REQUEST_CHANGES", "🛑"], ["Changes requested", ["REQUEST_CHANGES", "🛑"]],
}; ]);
const PROFILE_TYPES: Set<unknown> = new Set(["claude", "codex", "opencode"]); const PROFILE_TYPES: Set<unknown> = new Set(["claude", "codex", "opencode"]);
const FAILURE_REASONS: Set<unknown> = new Set(["sign-in failed", "usage exhausted", "failed"]); 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 isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value);
@@ -46,7 +46,11 @@ function parseAnchors(text: string, sourcePath: string): Anchor[] {
if (!isObject(entry)) { if (!isObject(entry)) {
throw new TypeError(`${at}: not an object`); 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" : givenSide;
const body = field(entry, "body");
if (typeof path !== "string" || path === "") { if (typeof path !== "string" || path === "") {
throw new TypeError(`${at}.path: expected a repository path`); throw new TypeError(`${at}.path: expected a repository path`);
} }
@@ -68,43 +72,42 @@ async function readOutcome(path: string): Promise<RuntimeOutcome> {
if (!isObject(value) || !("ok" in value)) { if (!isObject(value) || !("ok" in value)) {
throw new Error("invalid agent outcome"); throw new Error("invalid agent outcome");
} }
const outcome = value; const ok = field(value, "ok");
if (outcome["ok"] === false) { if (ok === false) {
// Only known labels reach the comment, never raw agent output. // 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 { return {
ok: false, ok: false,
failures: failures.flatMap((entry: unknown) => { failures: failures.flatMap((entry: unknown) => {
const { type, model, reason } = isObject(entry) ? entry : {}; const type = field(entry, "type");
const model = field(entry, "model");
const reason = field(entry, "reason");
return isProfileType(type) && typeof model === "string" && isFailureReason(reason) return isProfileType(type) && typeof model === "string" && isFailureReason(reason)
? [{ type, model, 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 ( if (
outcome["ok"] !== true || ok !== true ||
typeof outcome["body"] !== "string" || typeof body !== "string" ||
typeof outcome["reviewPath"] !== "string" || typeof reviewPath !== "string" ||
typeof outcome["anchorsPath"] !== "string" || typeof anchorsPath !== "string" ||
!isObject(outcome["profile"]) !isObject(profile)
) { ) {
throw new Error("invalid agent outcome"); throw new Error("invalid agent outcome");
} }
const profile = outcome["profile"]; const type = field(profile, "type");
if (!isProfileType(profile["type"]) || typeof profile["model"] !== "string") { const model = field(profile, "model");
if (!isProfileType(type) || typeof model !== "string") {
throw new Error("invalid agent outcome"); throw new Error("invalid agent outcome");
} }
return { return { ok: true, body, reviewPath, anchorsPath, profile: { type, model } };
ok: true,
body: outcome["body"],
reviewPath: outcome["reviewPath"],
anchorsPath: outcome["anchorsPath"],
profile: {
type: profile["type"],
model: profile["model"],
},
};
} }
async function readAgentArtifact( async function readAgentArtifact(
@@ -188,7 +191,7 @@ export async function postRuntime(
} }
const [verdict = "", ...rest] = review.split("\n"); const [verdict = "", ...rest] = review.split("\n");
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"]; const [event, mark] = VERDICTS.get(verdict.trim()) ?? ["COMMENT", "💬"];
const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json"); const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json");
const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath); const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath);
const postReview = (entries: Anchor[]): Promise<void> => const postReview = (entries: Anchor[]): Promise<void> =>
+8 -5
View File
@@ -25,14 +25,14 @@ function runAs(
selected: RuntimeProfile, selected: RuntimeProfile,
command: string, command: string,
args: string[], args: string[],
env?: NodeJS.ProcessEnv, env?: ReadonlyMap<string, string>,
): SpawnSyncReturns<string> { ): SpawnSyncReturns<string> {
return spawnSync(command, args, { return spawnSync(command, args, {
cwd: "/", cwd: "/",
uid: selected.uid, uid: selected.uid,
gid: selected.gid, gid: selected.gid,
encoding: "utf8", encoding: "utf8",
...(env !== undefined && { env }), ...(env !== undefined && { env: Object.fromEntries(env) }),
}); });
} }
@@ -104,9 +104,12 @@ test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async ()
test.skipIf(!IS_ROOT)("the agent process cannot read its coordinator environment", () => { test.skipIf(!IS_ROOT)("the agent process cannot read its coordinator environment", () => {
const selected = profile(20_020, "/tmp"); const selected = profile(20_020, "/tmp");
const result = runAs(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], { const result = runAs(
PARENT_SECRET: "must-not-cross-boundary", selected,
}); "/usr/bin/cat",
[`/proc/${process.pid}/environ`],
new Map([["PARENT_SECRET", "must-not-cross-boundary"]]),
);
expect(result.status).not.toBe(0); expect(result.status).not.toBe(0);
}); });
+43 -33
View File
@@ -157,20 +157,22 @@ test("does not mistake incidental quota text for exhaustion", async () => {
}); });
test("exposes only the selected profile credential to the agent", () => { test("exposes only the selected profile credential to the agent", () => {
const base = { const base = new Map(
PATH: "/usr/bin", Object.entries({
HOME: "/home/bot", PATH: "/usr/bin",
UNRELATED_SECRET: "must-not-reach-agent", HOME: "/home/bot",
BOT_AGENTS_MASTER_KEY: "master", UNRELATED_SECRET: "must-not-reach-agent",
PROFILES: "contains every secret", BOT_AGENTS_MASTER_KEY: "master",
REVIEWER_TOKEN: "reviewer", PROFILES: "contains every secret",
CLAUDE_CODE_OAUTH_TOKEN: "old-claude", REVIEWER_TOKEN: "reviewer",
ANTHROPIC_API_KEY: "old-anthropic-key", CLAUDE_CODE_OAUTH_TOKEN: "old-claude",
ANTHROPIC_AUTH_TOKEN: "old-anthropic-token", ANTHROPIC_API_KEY: "old-anthropic-key",
OPENAI_API_KEY: "old-openai", ANTHROPIC_AUTH_TOKEN: "old-anthropic-token",
CODEX_API_KEY: "old-codex-key", OPENAI_API_KEY: "old-openai",
CODEX_ACCESS_TOKEN: "old-codex-token", CODEX_API_KEY: "old-codex-key",
}; CODEX_ACCESS_TOKEN: "old-codex-token",
}),
);
const profile = { const profile = {
index: 0, index: 0,
type: "claude" as const, type: "claude" as const,
@@ -181,15 +183,19 @@ test("exposes only the selected profile credential to the agent", () => {
tokenFile: "/runtime/profile-0/token", tokenFile: "/runtime/profile-0/token",
}; };
expect(agentEnvironment(base, profile, "selected")).toStrictEqual({ expect(agentEnvironment(base, profile, "selected")).toStrictEqual(
PATH: "/usr/bin", new Map([
HOME: "/runtime/profile-0", ["PATH", "/usr/bin"],
CLAUDE_CODE_OAUTH_TOKEN: "selected", ["HOME", "/runtime/profile-0"],
}); ["CLAUDE_CODE_OAUTH_TOKEN", "selected"],
expect(sanitizedEnvironment(base)).toStrictEqual({ ]),
PATH: "/usr/bin", );
HOME: "/home/bot", expect(sanitizedEnvironment(base)).toStrictEqual(
}); new Map([
["PATH", "/usr/bin"],
["HOME", "/home/bot"],
]),
);
}); });
test("uses a private Codex home without inventing an API credential", () => { test("uses a private Codex home without inventing an API credential", () => {
@@ -203,11 +209,13 @@ test("uses a private Codex home without inventing an API credential", () => {
tokenFile: null, tokenFile: null,
}; };
expect(agentEnvironment({ PATH: "/bin" }, profile)).toStrictEqual({ expect(agentEnvironment(new Map([["PATH", "/bin"]]), profile)).toStrictEqual(
PATH: "/bin", new Map([
HOME: "/runtime/profile-1", ["PATH", "/bin"],
CODEX_HOME: "/runtime/profile-1/.codex", ["HOME", "/runtime/profile-1"],
}); ["CODEX_HOME", "/runtime/profile-1/.codex"],
]),
);
}); });
test("exposes an OpenRouter key only to the OpenCode profile", () => { test("exposes an OpenRouter key only to the OpenCode profile", () => {
@@ -221,9 +229,11 @@ test("exposes an OpenRouter key only to the OpenCode profile", () => {
tokenFile: "/runtime/profile-2/token", tokenFile: "/runtime/profile-2/token",
}; };
expect(agentEnvironment({ PATH: "/bin" }, profile, "selected")).toStrictEqual({ expect(agentEnvironment(new Map([["PATH", "/bin"]]), profile, "selected")).toStrictEqual(
PATH: "/bin", new Map([
HOME: "/runtime/profile-2", ["PATH", "/bin"],
OPENROUTER_API_KEY: "selected", ["HOME", "/runtime/profile-2"],
}); ["OPENROUTER_API_KEY", "selected"],
]),
);
}); });
+13 -9
View File
@@ -80,8 +80,12 @@ export function runProfiles<T, P extends ProfileIdentity>(
return attemptFrom(profiles, attempt, failures, 0); return attemptFrom(profiles, attempt, failures, 0);
} }
export function sanitizedEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv { // Environments are maps from variable name to value, converted to the object
const result: NodeJS.ProcessEnv = {}; // form only where a child process is spawned.
export function sanitizedEnvironment(
environment: ReadonlyMap<string, string>,
): Map<string, string> {
const result: Map<string, string> = new Map();
for (const name of [ for (const name of [
"PATH", "PATH",
"HOME", "HOME",
@@ -108,27 +112,27 @@ export function sanitizedEnvironment(environment: NodeJS.ProcessEnv): NodeJS.Pro
"SSL_CERT_DIR", "SSL_CERT_DIR",
"NODE_EXTRA_CA_CERTS", "NODE_EXTRA_CA_CERTS",
]) { ]) {
const value = environment[name]; const value = environment.get(name);
if (value !== undefined) result[name] = value; if (value !== undefined) result.set(name, value);
} }
return result; return result;
} }
export function agentEnvironment( export function agentEnvironment(
environment: NodeJS.ProcessEnv, environment: ReadonlyMap<string, string>,
profile: ProfileHome, profile: ProfileHome,
token?: string, token?: string,
): NodeJS.ProcessEnv { ): Map<string, string> {
const result = sanitizedEnvironment(environment); const result = sanitizedEnvironment(environment);
result["HOME"] = profile.home; result.set("HOME", profile.home);
if (profile.type === "codex") result["CODEX_HOME"] = `${profile.home}/.codex`; if (profile.type === "codex") result.set("CODEX_HOME", `${profile.home}/.codex`);
if (token !== undefined && token !== "") { if (token !== undefined && token !== "") {
const tokenName = { const tokenName = {
claude: "CLAUDE_CODE_OAUTH_TOKEN", claude: "CLAUDE_CODE_OAUTH_TOKEN",
codex: "OPENAI_API_KEY", codex: "OPENAI_API_KEY",
opencode: "OPENROUTER_API_KEY", opencode: "OPENROUTER_API_KEY",
}[profile.type]; }[profile.type];
result[tokenName] = token; result.set(tokenName, token);
} }
return result; return result;
} }
+44 -39
View File
@@ -24,7 +24,7 @@ import {
retryInvalidToken, retryInvalidToken,
stageCodexAuth, stageCodexAuth,
} from "#agents"; } from "#agents";
import { isObject } from "#common"; import { field, isObject } from "#common";
import { echo, exitCode, killProfileProcesses, readProfileFile } from "#processes"; import { echo, exitCode, killProfileProcesses, readProfileFile } from "#processes";
import { import {
AgentAuthError, AgentAuthError,
@@ -62,11 +62,11 @@ const env = (name: string): string => {
return value; return value;
}; };
function definedEnvironment(): NodeJS.ProcessEnv { function definedEnvironment(): Map<string, string> {
const result: NodeJS.ProcessEnv = {}; const result: Map<string, string> = new Map();
for (const [name, value] of Object.entries(process.env)) { for (const [name, value] of Object.entries(process.env)) {
if (value !== undefined) { if (value !== undefined) {
result[name] = value; result.set(name, value);
} }
} }
return result; return result;
@@ -104,17 +104,21 @@ async function postAuthorComment(body: string): Promise<void> {
} }
async function runRoot(command: string, args: string[]): Promise<void> { async function runRoot(command: string, args: string[]): Promise<void> {
const code = await exitCode(spawn(command, args, { env: PROCESS_ENV, stdio: "ignore" })); 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}`); if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`);
} }
async function configureGitAuthor(): Promise<void> { async function configureGitAuthor(): Promise<void> {
const user: unknown = await (await gitea("user")).json(); const user: unknown = await (await gitea("user")).json();
if (!isObject(user) || typeof user["login"] !== "string" || typeof user["email"] !== "string") { const login = field(user, "login");
throw new Error("user: unexpected Gitea response"); 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.name", login]);
await runRoot("git", ["config", "user.email", user["email"]]); await runRoot("git", ["config", "user.email", email]);
} }
async function readProfileToken(profile: RuntimeProfile): Promise<string | null> { async function readProfileToken(profile: RuntimeProfile): Promise<string | null> {
@@ -130,7 +134,7 @@ async function runAgentCommand(
): Promise<void> { ): Promise<void> {
const child = spawn(command, args, { const child = spawn(command, args, {
cwd: WORKSPACE, cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token), env: Object.fromEntries(agentEnvironment(PROCESS_ENV, profile, token)),
uid: profile.uid, uid: profile.uid,
gid: profile.gid, gid: profile.gid,
stdio: "ignore", stdio: "ignore",
@@ -159,8 +163,9 @@ async function prepareProfile(profile: RuntimeProfile): Promise<void> {
// One issue comment as the prompt quotes it. // One issue comment as the prompt quotes it.
function formatComment(comment: unknown): string { function formatComment(comment: unknown): string {
const { user, created_at: createdAt, body } = isObject(comment) ? comment : {}; const login = field(field(comment, "user"), "login");
const login = isObject(user) ? user["login"] : undefined; const createdAt = field(comment, "created_at");
const body = field(comment, "body");
return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`; return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`;
} }
@@ -173,22 +178,22 @@ async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
if (!Array.isArray(comments)) { if (!Array.isArray(comments)) {
throw new TypeError("comments: unexpected Gitea response"); throw new TypeError("comments: unexpected Gitea response");
} }
const values: NodeJS.ProcessEnv = { const values: Map<string, string> = new Map([
COMMENT: env("COMMENT"), ["COMMENT", env("COMMENT")],
ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"), ["ISSUE_COMMENTS", comments.map((comment: unknown) => formatComment(comment)).join("\n")],
CODE_RULES: await (await gitea(RULES_PATH)).text(), ["CODE_RULES", await (await gitea(RULES_PATH)).text()],
EVENT_NAME: EVENT, ["EVENT_NAME", EVENT],
GITEA_API_URL: API, ["GITEA_API_URL", API],
GITEA_REPOSITORY: REPO, ["GITEA_REPOSITORY", REPO],
ISSUE_INDEX: INDEX, ["ISSUE_INDEX", INDEX],
REVIEW_PATH: reviewPath, ["REVIEW_PATH", reviewPath],
ANCHORS_PATH: anchorsPath, ["ANCHORS_PATH", anchorsPath],
}; ]);
const template = await readFile(new URL("../../prompt.md", import.meta.url), "utf8"); const template = await readFile(new URL("../../prompt.md", import.meta.url), "utf8");
return { return {
prompt: template.replaceAll( prompt: template.replaceAll(
/\$\{(\w+)\}/gu, /\$\{(\w+)\}/gu,
(match: string, name: string) => values[name] ?? match, (match: string, name: string) => values.get(name) ?? match,
), ),
reviewPath, reviewPath,
anchorsPath, anchorsPath,
@@ -210,10 +215,7 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
], ],
{ {
cwd: WORKSPACE, cwd: WORKSPACE,
env: { env: Object.fromEntries(agentEnvironment(PROCESS_ENV, profile, token).set("IS_SANDBOX", "1")),
...agentEnvironment(PROCESS_ENV, profile, token),
IS_SANDBOX: "1",
},
uid: profile.uid, uid: profile.uid,
gid: profile.gid, gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
@@ -234,17 +236,20 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
if (line !== "") { if (line !== "") {
const event: unknown = JSON.parse(line); const event: unknown = JSON.parse(line);
if (isObject(event)) { 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 : []) { for (const part of Array.isArray(content) ? content : []) {
if (isObject(part)) { 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 !== "") { if (typeof text === "string" && text !== "") {
console.log(text); console.log(text);
} }
} }
} }
if (event["type"] === "result") { if (field(event, "type") === "result") {
const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event; const isError = field(event, "is_error");
const answer = field(event, "result");
const subtype = field(event, "subtype");
const apiStatus = field(event, "api_error_status");
result = { result = {
isError: typeof isError === "boolean" ? isError : undefined, isError: typeof isError === "boolean" ? isError : undefined,
result: typeof answer === "string" ? answer : undefined, result: typeof answer === "string" ? answer : undefined,
@@ -289,11 +294,11 @@ async function pollForInstructions(
async function codexDeviceLogin( async function codexDeviceLogin(
profile: RuntimeProfile, profile: RuntimeProfile,
environment: NodeJS.ProcessEnv, environment: ReadonlyMap<string, string>,
): Promise<void> { ): Promise<void> {
const login = spawn("codex", ["login", "--device-auth"], { const login = spawn("codex", ["login", "--device-auth"], {
cwd: WORKSPACE, cwd: WORKSPACE,
env: environment, env: Object.fromEntries(environment),
uid: profile.uid, uid: profile.uid,
gid: profile.gid, gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
@@ -318,12 +323,12 @@ async function codexDeviceLogin(
async function codexApiLogin( async function codexApiLogin(
profile: RuntimeProfile, profile: RuntimeProfile,
environment: NodeJS.ProcessEnv, environment: ReadonlyMap<string, string>,
token: string, token: string,
): Promise<void> { ): Promise<void> {
const login = spawn("codex", ["login", "--with-api-key"], { const login = spawn("codex", ["login", "--with-api-key"], {
cwd: WORKSPACE, cwd: WORKSPACE,
env: environment, env: Object.fromEntries(environment),
uid: profile.uid, uid: profile.uid,
gid: profile.gid, gid: profile.gid,
stdio: ["pipe", "inherit", "pipe"], stdio: ["pipe", "inherit", "pipe"],
@@ -357,7 +362,7 @@ async function runCodex(
} else { } else {
const check = spawn("codex", ["login", "status"], { const check = spawn("codex", ["login", "status"], {
cwd: WORKSPACE, cwd: WORKSPACE,
env: environment, env: Object.fromEntries(environment),
uid: profile.uid, uid: profile.uid,
gid: profile.gid, gid: profile.gid,
stdio: "ignore", stdio: "ignore",
@@ -379,7 +384,7 @@ async function runCodex(
], ],
{ {
cwd: WORKSPACE, cwd: WORKSPACE,
env: environment, env: Object.fromEntries(environment),
uid: profile.uid, uid: profile.uid,
gid: profile.gid, gid: profile.gid,
stdio: ["ignore", "inherit", "pipe"], stdio: ["ignore", "inherit", "pipe"],
@@ -420,7 +425,7 @@ async function runOpenCode(
): Promise<string> { ): Promise<string> {
const child = spawn("opencode", openCodeArgs(profile.model, prompt), { const child = spawn("opencode", openCodeArgs(profile.model, prompt), {
cwd: WORKSPACE, cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token), env: Object.fromEntries(agentEnvironment(PROCESS_ENV, profile, token)),
uid: profile.uid, uid: profile.uid,
gid: profile.gid, gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
+6 -3
View File
@@ -1,6 +1,6 @@
import { webcrypto } from "node:crypto"; import { webcrypto } from "node:crypto";
import { isMapping } from "#common"; import { field, isMapping } from "#common";
import type { BotProfile } from "#profiles"; import type { BotProfile } from "#profiles";
interface DefaultModels { interface DefaultModels {
@@ -80,7 +80,9 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P
throw new Error(`${at}.${key}: unknown field`); throw new Error(`${at}.${key}: unknown field`);
} }
} }
const { type, model, token } = entry; const type = field(entry, "type");
const model = field(entry, "model");
const token = field(entry, "token");
if (type !== "claude" && type !== "codex" && type !== "opencode") { if (type !== "claude" && type !== "codex" && type !== "opencode") {
throw new Error(`${at}.type: expected "claude", "codex", or "opencode"`); throw new Error(`${at}.type: expected "claude", "codex", or "opencode"`);
} }
@@ -112,7 +114,8 @@ export async function parseCredentials(text: string, masterKey: string): Promise
throw new Error(`${key}: unknown field`); 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") { if (typeof reviewerToken !== "string") {
throw new TypeError("reviewer-token: expected an encrypted credential"); throw new TypeError("reviewer-token: expected an encrypted credential");
} }
+6 -6
View File
@@ -1,7 +1,7 @@
import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { isMapping } from "#common"; import { field, isMapping } from "#common";
import type { ProfileFailure, ProfileIdentity } from "#profiles"; import type { ProfileFailure, ProfileIdentity } from "#profiles";
import { parseCredentials } from "./credentials.ts"; import { parseCredentials } from "./credentials.ts";
@@ -107,11 +107,11 @@ export async function prepareRuntime(
function isRuntimeManifest(value: unknown): value is RuntimeManifest { function isRuntimeManifest(value: unknown): value is RuntimeManifest {
return ( return (
isMapping(value) && isMapping(value) &&
typeof value["runtimeDir"] === "string" && typeof field(value, "runtimeDir") === "string" &&
typeof value["manifestPath"] === "string" && typeof field(value, "manifestPath") === "string" &&
typeof value["reviewerFile"] === "string" && typeof field(value, "reviewerFile") === "string" &&
typeof value["outcomeFile"] === "string" && typeof field(value, "outcomeFile") === "string" &&
Array.isArray(value["profiles"]) Array.isArray(field(value, "profiles"))
); );
} }
+16 -4
View File
@@ -1,5 +1,5 @@
// Test support: the current user's IDs, a fresh temporary directory, and a // Test support: the current user's IDs, a fresh temporary directory, the
// plain command runner. It imports no source module, so the repository-level // 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 // tests in tests/, which reach src/ only through `#testing`, never touch
// internals. // internals.
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
@@ -33,14 +33,26 @@ export interface CommandResult {
stderr: string; stderr: string;
} }
// This process's environment as a map from variable name to value.
export function inheritedEnvironment(): Map<string, string> {
const result: Map<string, string> = new Map();
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 // Runs a command with stdin closed and only the given environment, and
// collects its stderr. // collects its stderr.
export async function runCommand( export async function runCommand(
command: string, command: string,
args: string[], args: string[],
env: NodeJS.ProcessEnv, env: ReadonlyMap<string, string>,
): Promise<CommandResult> { ): Promise<CommandResult> {
const child = spawn(command, args, { env, stdio: ["ignore", "ignore", "pipe"] }); 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 [stderr, closed] = await Promise.all([text(child.stderr), once(child, "close")]);
const exitCode: unknown = closed[0]; const exitCode: unknown = closed[0];
return { success: exitCode === 0, stderr }; return { success: exitCode === 0, stderr };
+5 -1
View File
@@ -15,7 +15,11 @@ const PHASES: [phase: string, firstVariable: string][] = [
test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => { test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => {
const path = fileURLToPath(new URL(`../src/${phase}/index.ts`, import.meta.url)); const path = fileURLToPath(new URL(`../src/${phase}/index.ts`, import.meta.url));
const result = await runCommand(process.execPath, [path], { PATH: process.env["PATH"] ?? "" }); const result = await runCommand(
process.execPath,
[path],
new Map([["PATH", process.env["PATH"] ?? ""]]),
);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.stderr).toContain(`${firstVariable} is not set`); expect(result.stderr).toContain(`${firstVariable} is not set`);
}); });
+48 -43
View File
@@ -3,24 +3,22 @@ import process from "node:process";
import { assert, expect, test } from "vite-plus/test"; import { assert, expect, test } from "vite-plus/test";
import { parse } from "yaml"; import { parse } from "yaml";
import { makeTempDir, runCommand } from "#testing"; import { inheritedEnvironment, makeTempDir, runCommand } from "#testing";
type JsonObject = NodeJS.Dict<unknown>; // A parsed YAML or JSON mapping, keyed by its dynamic field names.
type Mapping = Map<string, unknown>;
const isMapping = (value: unknown): value is JsonObject => const mapping = (value: unknown, at: string): Mapping => {
typeof value === "object" && value !== null && !Array.isArray(value); assert(typeof value === "object" && value !== null && !Array.isArray(value), at);
return new Map(Object.entries(value));
const mapping = (value: unknown, at: string): JsonObject => {
assert(isMapping(value), at);
return value;
}; };
const sequence = (value: unknown, at: string): JsonObject[] => { const sequence = (value: unknown, at: string): Mapping[] => {
assert(Array.isArray(value), at); assert(Array.isArray(value), at);
return value.map((entry: unknown, index) => mapping(entry, `${at}[${index}]`)); return value.map((entry: unknown, index) => mapping(entry, `${at}[${index}]`));
}; };
async function yaml(path: string): Promise<JsonObject> { async function yaml(path: string): Promise<Mapping> {
const value: unknown = parse(await readFile(path, "utf8")); const value: unknown = parse(await readFile(path, "utf8"));
return mapping(value, path); return mapping(value, path);
} }
@@ -33,8 +31,8 @@ async function yamlFiles(): Promise<string[]> {
.filter((path) => !/^(?:\.git|\.superpowers|node_modules)\//u.test(path)); .filter((path) => !/^(?:\.git|\.superpowers|node_modules)\//u.test(path));
} }
const findStep = (steps: JsonObject[], key: string, value: string): JsonObject => { const findStep = (steps: Mapping[], key: string, value: string): Mapping => {
const step = steps.find((entry) => entry[key] === value); const step = steps.find((entry) => entry.get(key) === value);
assert(step !== undefined, `${key}: ${value}`); assert(step !== undefined, `${key}: ${value}`);
return step; return step;
}; };
@@ -47,11 +45,12 @@ test("all committed YAML parses", async () => {
test("the action exposes only the master key and scopes it to preparation", async () => { test("the action exposes only the master key and scopes it to preparation", async () => {
const action = await yaml("action.yml"); const action = await yaml("action.yml");
expect(Object.keys(mapping(action["inputs"], "inputs"))).toStrictEqual(["master-key"]); expect([...mapping(action.get("inputs"), "inputs").keys()]).toStrictEqual(["master-key"]);
const steps = sequence(mapping(action["runs"], "runs")["steps"], "runs.steps"); const steps = sequence(mapping(action.get("runs"), "runs").get("steps"), "runs.steps");
for (const step of steps) { for (const step of steps) {
if (typeof step["run"] === "string") { const run = step.get("run");
expect(step["run"].includes("\n"), String(step["name"])).toBe(false); if (typeof run === "string") {
expect(run.includes("\n"), String(step.get("name"))).toBe(false);
} }
} }
const checkout = findStep(steps, "uses", "actions/checkout@v4"); const checkout = findStep(steps, "uses", "actions/checkout@v4");
@@ -59,16 +58,16 @@ test("the action exposes only the master key and scopes it to preparation", asyn
const agent = findStep(steps, "id", "agent"); const agent = findStep(steps, "id", "agent");
const post = findStep(steps, "id", "post"); const post = findStep(steps, "id", "post");
findStep(steps, "id", "source"); findStep(steps, "id", "source");
expect(mapping(checkout["with"], "checkout.with")["token"]).toBe("${{ gitea.token }}"); expect(mapping(checkout.get("with"), "checkout.with").get("token")).toBe("${{ gitea.token }}");
expect(mapping(prepare["env"], "prepare.env")["BOT_AGENTS_MASTER_KEY"]).toBe( expect(mapping(prepare.get("env"), "prepare.env").get("BOT_AGENTS_MASTER_KEY")).toBe(
"${{ inputs.master-key }}", "${{ inputs.master-key }}",
); );
expect(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY")).toBe(false); expect(JSON.stringify(Object.fromEntries(agent)).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY")).toBe(false); expect(JSON.stringify(Object.fromEntries(post)).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(mapping(agent["env"], "agent.env")["GITEA_TOKEN"]).toBe("${{ gitea.token }}"); expect(mapping(agent.get("env"), "agent.env").get("GITEA_TOKEN")).toBe("${{ gitea.token }}");
assert(String(post["if"]).includes("always()")); assert(String(post.get("if")).includes("always()"));
for (const step of [prepare, agent, post]) { for (const step of [prepare, agent, post]) {
assert(String(step["run"]).includes("steps.source.outputs.path")); assert(String(step.get("run")).includes("steps.source.outputs.path"));
} }
}); });
@@ -76,12 +75,11 @@ test("the source protection script stages immutable action files", async () => {
const temp = await makeTempDir(); const temp = await makeTempDir();
const outputFile = `${temp}/output`; const outputFile = `${temp}/output`;
try { try {
const result = await runCommand("/bin/bash", ["scripts/protect-source.sh"], { const environment = inheritedEnvironment();
...process.env, environment.set("ACTION_PATH", process.cwd());
ACTION_PATH: process.cwd(), environment.set("GITHUB_OUTPUT", outputFile);
GITHUB_OUTPUT: outputFile, environment.set("RUNNER_TEMP", temp);
RUNNER_TEMP: temp, const result = await runCommand("/bin/bash", ["scripts/protect-source.sh"], environment);
});
assert(result.success, result.stderr); assert(result.success, result.stderr);
const path = (await readFile(outputFile, "utf8")).trim().slice(5); const path = (await readFile(outputFile, "utf8")).trim().slice(5);
@@ -119,27 +117,30 @@ test("module-relative runtime assets exist", async () => {
test("the repository uses its trusted main action", async () => { test("the repository uses its trusted main action", async () => {
const workflow = await yaml(".gitea/workflows/agents.yml"); const workflow = await yaml(".gitea/workflows/agents.yml");
const job = mapping(mapping(workflow["jobs"], "jobs")["agent"], "jobs.agent"); const job = mapping(mapping(workflow.get("jobs"), "jobs").get("agent"), "jobs.agent");
const permissions = mapping(job["permissions"], "jobs.agent.permissions"); const permissions = mapping(job.get("permissions"), "jobs.agent.permissions");
expect(permissions).toStrictEqual({ expect(Object.fromEntries(permissions)).toStrictEqual({
code: "write", code: "write",
issues: "write", issues: "write",
"pull-requests": "write", "pull-requests": "write",
}); });
const steps = sequence(job["steps"], "jobs.agent.steps"); const steps = sequence(job.get("steps"), "jobs.agent.steps");
expect(steps.findIndex((step) => step["uses"] === "./")).toStrictEqual(-1); expect(steps.findIndex((step) => step.get("uses") === "./")).toStrictEqual(-1);
const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main"); const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main");
expect(mapping(trusted["with"], "trusted.with")["master-key"]).toBe( expect(mapping(trusted.get("with"), "trusted.with").get("master-key")).toBe(
"${{ secrets.BOT_AGENTS_MASTER_KEY }}", "${{ secrets.BOT_AGENTS_MASTER_KEY }}",
); );
expect(JSON.stringify(workflow).includes("gitea.actor != 'bot'")).toBe(true); expect(JSON.stringify(Object.fromEntries(workflow)).includes("gitea.actor != 'bot'")).toBe(true);
}); });
test("CI checks every phase and the root isolation suite", async () => { test("CI checks every phase and the root isolation suite", async () => {
const workflow = await yaml(".gitea/workflows/check.yml"); const workflow = await yaml(".gitea/workflows/check.yml");
const job = mapping(mapping(workflow["jobs"], "jobs")["check"], "jobs.check"); const job = mapping(mapping(workflow.get("jobs"), "jobs").get("check"), "jobs.check");
const commands = sequence(job["steps"], "jobs.check.steps") const commands = sequence(job.get("steps"), "jobs.check.steps")
.map((step) => (typeof step["run"] === "string" ? step["run"] : "")) .map((step) => {
const run = step.get("run");
return typeof run === "string" ? run : "";
})
.join("\n"); .join("\n");
// `vp check` type-checks every phase, and `vp test` runs each of them under // `vp check` type-checks every phase, and `vp test` runs each of them under
// Node as well as the root-only isolation tests. // Node as well as the root-only isolation tests.
@@ -176,8 +177,12 @@ test("profile preparation does not receive provider credentials", async () => {
test("central profiles fall back from Codex to Claude to OpenCode", async () => { test("central profiles fall back from Codex to Claude to OpenCode", async () => {
const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8")); const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8"));
const profiles = sequence(mapping(credentials, "profiles.json")["profiles"], "profiles"); const profiles = sequence(mapping(credentials, "profiles.json").get("profiles"), "profiles");
expect(profiles.map((profile) => profile["type"])).toStrictEqual(["codex", "claude", "opencode"]); expect(profiles.map((profile) => profile.get("type"))).toStrictEqual([
expect(profiles[2]?.["model"]).toBe("openrouter/qwen/qwen3.8-27b"); "codex",
assert(String(profiles[2]?.["token"]).startsWith("v1:")); "claude",
"opencode",
]);
expect(profiles[2]?.get("model")).toBe("openrouter/qwen/qwen3.8-27b");
assert(String(profiles[2]?.get("token")).startsWith("v1:"));
}); });