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";
import { dirname, join } from "node:path";
import { isNotFound, isObject } from "#common";
import { field, isNotFound, isObject } from "#common";
interface CodexApiKey {
kind: "api-key";
@@ -89,15 +89,16 @@ async function readCodexAuth(path: string): Promise<string> {
} 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] !== "",
)
!["access_token", "refresh_token", "id_token", "account_id"].every((name) => {
const token = field(tokens, name);
return typeof token === "string" && token !== "";
})
) {
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";
export function openCodeArgs(model: string, prompt: string): string[] {
@@ -56,13 +56,14 @@ export function openCodeError(output: string): Error | null {
} 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"}`;
@@ -81,10 +82,9 @@ export function parseOpenCodeEvents(output: string): string {
for (const line of output.split("\n")) {
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;
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) {
+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";
+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`.
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;
}
+31 -28
View File
@@ -2,7 +2,7 @@ import type { Stats } from "node:fs";
import { lstat, readFile, rm } from "node:fs/promises";
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 { 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 stripAnsi = (text: string): string => text.replace(ANSI, "");
const VERDICTS: NodeJS.Dict<[event: string, mark: string]> = {
Approved: ["APPROVED", "✅"],
"Changes requested": ["REQUEST_CHANGES", "🛑"],
};
const VERDICTS: Map<string, [event: string, mark: string]> = new Map([
["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);
@@ -46,7 +46,11 @@ function parseAnchors(text: string, sourcePath: string): Anchor[] {
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" : givenSide;
const body = field(entry, "body");
if (typeof path !== "string" || 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)) {
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 : {};
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(
@@ -188,7 +191,7 @@ export async function postRuntime(
}
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 anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath);
const postReview = (entries: Anchor[]): Promise<void> =>
+8 -5
View File
@@ -25,14 +25,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) }),
});
}
@@ -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", () => {
const selected = profile(20_020, "/tmp");
const result = runAs(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], {
PARENT_SECRET: "must-not-cross-boundary",
});
const result = runAs(
selected,
"/usr/bin/cat",
[`/proc/${process.pid}/environ`],
new Map([["PARENT_SECRET", "must-not-cross-boundary"]]),
);
expect(result.status).not.toBe(0);
});
+31 -21
View File
@@ -157,7 +157,8 @@ test("does not mistake incidental quota text for exhaustion", async () => {
});
test("exposes only the selected profile credential to the agent", () => {
const base = {
const base = new Map(
Object.entries({
PATH: "/usr/bin",
HOME: "/home/bot",
UNRELATED_SECRET: "must-not-reach-agent",
@@ -170,7 +171,8 @@ test("exposes only the selected profile credential to the agent", () => {
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,
@@ -181,15 +183,19 @@ 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", () => {
@@ -203,11 +209,13 @@ 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", () => {
@@ -221,9 +229,11 @@ 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"],
]),
);
});
+13 -9
View File
@@ -80,8 +80,12 @@ export function runProfiles<T, P extends ProfileIdentity>(
return attemptFrom(profiles, attempt, failures, 0);
}
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: Map<string, string> = new Map();
for (const name of [
"PATH",
"HOME",
@@ -108,27 +112,27 @@ 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;
}
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;
result.set(tokenName, token);
}
return result;
}
+44 -39
View File
@@ -24,7 +24,7 @@ import {
retryInvalidToken,
stageCodexAuth,
} from "#agents";
import { isObject } from "#common";
import { field, isObject } from "#common";
import { echo, exitCode, killProfileProcesses, readProfileFile } from "#processes";
import {
AgentAuthError,
@@ -62,11 +62,11 @@ const env = (name: string): string => {
return value;
};
function definedEnvironment(): NodeJS.ProcessEnv {
const result: NodeJS.ProcessEnv = {};
function definedEnvironment(): Map<string, string> {
const result: Map<string, string> = new Map();
for (const [name, value] of Object.entries(process.env)) {
if (value !== undefined) {
result[name] = value;
result.set(name, value);
}
}
return result;
@@ -104,17 +104,21 @@ 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" }));
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 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> {
@@ -130,7 +134,7 @@ 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",
@@ -159,8 +163,9 @@ async function prepareProfile(profile: RuntimeProfile): Promise<void> {
// 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`;
}
@@ -173,22 +178,22 @@ async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
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 values: Map<string, string> = new Map([
["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");
return {
prompt: template.replaceAll(
/\$\{(\w+)\}/gu,
(match: string, name: string) => values[name] ?? match,
(match: string, name: string) => values.get(name) ?? match,
),
reviewPath,
anchorsPath,
@@ -210,10 +215,7 @@ 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"],
@@ -234,17 +236,20 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
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,
@@ -289,11 +294,11 @@ async function pollForInstructions(
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"],
@@ -318,12 +323,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"],
@@ -357,7 +362,7 @@ async function runCodex(
} else {
const check = spawn("codex", ["login", "status"], {
cwd: WORKSPACE,
env: environment,
env: Object.fromEntries(environment),
uid: profile.uid,
gid: profile.gid,
stdio: "ignore",
@@ -379,7 +384,7 @@ async function runCodex(
],
{
cwd: WORKSPACE,
env: environment,
env: Object.fromEntries(environment),
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "inherit", "pipe"],
@@ -420,7 +425,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"],
+6 -3
View File
@@ -1,6 +1,6 @@
import { webcrypto } from "node:crypto";
import { isMapping } from "#common";
import { field, isMapping } from "#common";
import type { BotProfile } from "#profiles";
interface DefaultModels {
@@ -80,7 +80,9 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P
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") {
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`);
}
}
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");
}
+6 -6
View File
@@ -1,7 +1,7 @@
import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { isMapping } from "#common";
import { field, isMapping } from "#common";
import type { ProfileFailure, ProfileIdentity } from "#profiles";
import { parseCredentials } from "./credentials.ts";
@@ -107,11 +107,11 @@ 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"))
);
}
+16 -4
View File
@@ -1,5 +1,5 @@
// 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
// 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";
@@ -33,14 +33,26 @@ 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: 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
// 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 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 };
+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) => {
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.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 { 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 =>
typeof value === "object" && value !== null && !Array.isArray(value);
const mapping = (value: unknown, at: string): JsonObject => {
assert(isMapping(value), at);
return value;
const mapping = (value: unknown, at: string): Mapping => {
assert(typeof value === "object" && value !== null && !Array.isArray(value), at);
return new Map(Object.entries(value));
};
const sequence = (value: unknown, at: string): JsonObject[] => {
const sequence = (value: unknown, at: string): Mapping[] => {
assert(Array.isArray(value), at);
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"));
return mapping(value, path);
}
@@ -33,8 +31,8 @@ async function yamlFiles(): Promise<string[]> {
.filter((path) => !/^(?:\.git|\.superpowers|node_modules)\//u.test(path));
}
const findStep = (steps: JsonObject[], key: string, value: string): JsonObject => {
const step = steps.find((entry) => entry[key] === value);
const findStep = (steps: Mapping[], key: string, value: string): Mapping => {
const step = steps.find((entry) => entry.get(key) === value);
assert(step !== undefined, `${key}: ${value}`);
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 () => {
const action = await yaml("action.yml");
expect(Object.keys(mapping(action["inputs"], "inputs"))).toStrictEqual(["master-key"]);
const steps = sequence(mapping(action["runs"], "runs")["steps"], "runs.steps");
expect([...mapping(action.get("inputs"), "inputs").keys()]).toStrictEqual(["master-key"]);
const steps = sequence(mapping(action.get("runs"), "runs").get("steps"), "runs.steps");
for (const step of steps) {
if (typeof step["run"] === "string") {
expect(step["run"].includes("\n"), String(step["name"])).toBe(false);
const run = step.get("run");
if (typeof run === "string") {
expect(run.includes("\n"), String(step.get("name"))).toBe(false);
}
}
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 post = findStep(steps, "id", "post");
findStep(steps, "id", "source");
expect(mapping(checkout["with"], "checkout.with")["token"]).toBe("${{ gitea.token }}");
expect(mapping(prepare["env"], "prepare.env")["BOT_AGENTS_MASTER_KEY"]).toBe(
expect(mapping(checkout.get("with"), "checkout.with").get("token")).toBe("${{ gitea.token }}");
expect(mapping(prepare.get("env"), "prepare.env").get("BOT_AGENTS_MASTER_KEY")).toBe(
"${{ inputs.master-key }}",
);
expect(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(mapping(agent["env"], "agent.env")["GITEA_TOKEN"]).toBe("${{ gitea.token }}");
assert(String(post["if"]).includes("always()"));
expect(JSON.stringify(Object.fromEntries(agent)).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(JSON.stringify(Object.fromEntries(post)).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(mapping(agent.get("env"), "agent.env").get("GITEA_TOKEN")).toBe("${{ gitea.token }}");
assert(String(post.get("if")).includes("always()"));
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 outputFile = `${temp}/output`;
try {
const result = await runCommand("/bin/bash", ["scripts/protect-source.sh"], {
...process.env,
ACTION_PATH: process.cwd(),
GITHUB_OUTPUT: outputFile,
RUNNER_TEMP: temp,
});
const environment = inheritedEnvironment();
environment.set("ACTION_PATH", process.cwd());
environment.set("GITHUB_OUTPUT", outputFile);
environment.set("RUNNER_TEMP", temp);
const result = await runCommand("/bin/bash", ["scripts/protect-source.sh"], environment);
assert(result.success, result.stderr);
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 () => {
const workflow = await yaml(".gitea/workflows/agents.yml");
const job = mapping(mapping(workflow["jobs"], "jobs")["agent"], "jobs.agent");
const permissions = mapping(job["permissions"], "jobs.agent.permissions");
expect(permissions).toStrictEqual({
const job = mapping(mapping(workflow.get("jobs"), "jobs").get("agent"), "jobs.agent");
const permissions = mapping(job.get("permissions"), "jobs.agent.permissions");
expect(Object.fromEntries(permissions)).toStrictEqual({
code: "write",
issues: "write",
"pull-requests": "write",
});
const steps = sequence(job["steps"], "jobs.agent.steps");
expect(steps.findIndex((step) => step["uses"] === "./")).toStrictEqual(-1);
const steps = sequence(job.get("steps"), "jobs.agent.steps");
expect(steps.findIndex((step) => step.get("uses") === "./")).toStrictEqual(-1);
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 }}",
);
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 () => {
const workflow = await yaml(".gitea/workflows/check.yml");
const job = mapping(mapping(workflow["jobs"], "jobs")["check"], "jobs.check");
const commands = sequence(job["steps"], "jobs.check.steps")
.map((step) => (typeof step["run"] === "string" ? step["run"] : ""))
const job = mapping(mapping(workflow.get("jobs"), "jobs").get("check"), "jobs.check");
const commands = sequence(job.get("steps"), "jobs.check.steps")
.map((step) => {
const run = step.get("run");
return typeof run === "string" ? run : "";
})
.join("\n");
// `vp check` type-checks every phase, and `vp test` runs each of them under
// 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 () => {
const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8"));
const profiles = sequence(mapping(credentials, "profiles.json")["profiles"], "profiles");
expect(profiles.map((profile) => profile["type"])).toStrictEqual(["codex", "claude", "opencode"]);
expect(profiles[2]?.["model"]).toBe("openrouter/qwen/qwen3.8-27b");
assert(String(profiles[2]?.["token"]).startsWith("v1:"));
const profiles = sequence(mapping(credentials, "profiles.json").get("profiles"), "profiles");
expect(profiles.map((profile) => profile.get("type"))).toStrictEqual([
"codex",
"claude",
"opencode",
]);
expect(profiles[2]?.get("model")).toBe("openrouter/qwen/qwen3.8-27b");
assert(String(profiles[2]?.get("token")).startsWith("v1:"));
});