Drop Deno from the agents and spawn child processes directly
Check / check (pull_request) Successful in 2m26s
Agents / agent (pull_request) Has been skipped

This commit is contained in:
Danny Kim
2026-10-16 23:26:28 +09:00
parent 006bbe6b0c
commit 50328caac5
21 changed files with 294 additions and 390 deletions
@@ -1,5 +1,5 @@
--- ---
description: bot-agents migration decisions: live @main action for six repos, JSON profiles, Node 24.2+, Deno kept only for agents, no @cunarist plugins needed description: bot-agents migration decisions: live @main action for six repos, JSON profiles, Node 24.2+, no @cunarist plugins needed
--- ---
Decisions for `commons/bot-agents` (Capsulizers Gitea), from the Deno to Decisions for `commons/bot-agents` (Capsulizers Gitea), from the Deno to
@@ -16,10 +16,6 @@ Node + Vite+ migration (PR #20, `node-migration`, 2026-10-15/16):
- **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. `engines.node` - **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. `engines.node`
is `>=24.2`, which the user accepted for this repo is `>=24.2`, which the user accepted for this repo
([[bot-agents-runtime-no-build]]). ([[bot-agents-runtime-no-build]]).
- **Deno stays in the action's global npm install** (`action.yml`), only
because the agents still check Deno-based consumer repos (memona,
memona-home). Remove it once those repos have moved; it is not a
dependency of this repo's TypeScript.
- **No `@cunarist/eslint-plugin-*`.** The Deno setup enabled no plugin, so - **No `@cunarist/eslint-plugin-*`.** The Deno setup enabled no plugin, so
this repo depends on none and CI passes without them being published. this repo depends on none and CI passes without them being published.
- **Stricter input checks were accepted** in the migration: Gitea `/user` and - **Stricter input checks were accepted** in the migration: Gitea `/user` and
@@ -0,0 +1,39 @@
---
description: Child processes use plain node:child_process spawn (explicit stdio, uid/gid, whole env); src/child.ts has exitCode (128+signo) and echo; no execFile maxBuffer
---
Every call site calls `spawn` from `node:child_process` itself, with
everything explicit:
- **`uid`/`gid`** of the profile for anything an agent profile runs (each
profile is its own UID, 20000+). Never drop them.
- **`env`** is the whole environment the child gets (a sanitized one for
agents); nothing from the coordinator is merged in.
- **`stdio`**: stdin is `"ignore"` so an agent CLI never waits on input
(only `codex login --with-api-key` pipes the token in). Output that is not
read is `"ignore"`; output that is read is `"pipe"` and streamed.
- No `execFile`/`exec`: their `maxBuffer` (1 MiB) kills a chatty child. Read
piped streams incrementally instead.
`src/child.ts` holds what the call sites share:
- **`exitCode(child)`**: call it right after `spawn`. It settles on `exit`
(not `close`, which a daemonized grandchild can hold open), maps a signal to
`128 + signo` as a shell does, and rejects on a failed start. The rejection
is pre-caught so it is not reported as unhandled before the caller awaits it.
- **`echo(stream, chunk)`**: writes and waits for `drain` when the buffer is
full, so echoing never outruns a slow reader.
Tests use `spawnSync` for short commands.
Other port details:
- **`src/errors.ts`** `isNotFound` checks `ENOENT` (file) or `ESRCH` (process).
- Temporary files are an exclusive `wx` create with mode `0600`.
- `src/json.ts` holds type guards used instead of `as` casts on parsed JSON.
- `process` and `Buffer` must come from `node:` imports:
`no-restricted-globals` in `scripts/project-lint.ts` enforces it
([[lint-parity-never-shrinks]]).
Related: how each agent CLI reports bad credentials is in
[[agent-cli-auth-failures]].
@@ -1,25 +0,0 @@
---
description: src/command.ts reproduces Deno.Command on child_process.spawn (stdio defaults, clearEnv, uid/gid, 128+signo); src/errors.ts maps NotFound to ENOENT/ESRCH
---
The Deno code was ported one for one; two small modules keep the old shapes
so callers barely changed:
- **`src/command.ts`** wraps `child_process.spawn` with `Deno.Command`'s API:
`output()` and `spawn()`, `clearEnv`, `uid`/`gid` (the agent isolation runs
each profile as its own UID, 20000+), and Deno's stdio defaults: stdin
inherited; stdout/stderr piped for `output()`, inherited for `spawn()`. A
signal exit maps to status `128 + signo`. Status settles on process exit,
not on stream close. Use it instead of calling `node:child_process`
directly so isolation and status handling stay uniform.
- **`src/errors.ts`** replaces `Deno.errors.NotFound` checks with an
`ENOENT`/`ESRCH` code check.
- `makeTempFile` became an exclusive `wx` create with mode `0600`.
- `src/json.ts` holds type guards used instead of `as` casts on parsed JSON.
- `process` and `Buffer` must come from `node:` imports:
`no-restricted-globals` in `scripts/project-lint.ts` maps Deno's
`no-window`/`no-process-global`/`no-node-globals` there
([[lint-parity-never-shrinks]]).
Related: how each agent CLI reports bad credentials is in
[[agent-cli-auth-failures]].
+2 -3
View File
@@ -25,6 +25,5 @@ list live in `scripts/project-lint.ts`. Lint disable comments are not allowed;
fix the source instead. fix the source instead.
The composite action uses npm only to install the externally distributed The composite action uses npm only to install the externally distributed
Claude Code, Codex, and OpenCode command-line tools, plus Deno for agents that Claude Code, Codex, and OpenCode command-line tools; do not treat those
check Deno-based repositories; do not treat those vendor executables as vendor executables as repository TypeScript dependencies.
repository TypeScript dependencies.
+2 -3
View File
@@ -16,11 +16,10 @@ runs:
fetch-depth: 0 fetch-depth: 0
token: ${{ gitea.token }} token: ${{ gitea.token }}
# This step assumes this is `node:24-bookworm` container. The action runs # This step assumes this is `node:24-bookworm` container. The action runs
# its TypeScript on that Node directly; Deno stays for the agents, which # its TypeScript on that Node directly.
# still check Deno-based repositories with it.
- name: Install dependencies - name: Install dependencies
shell: bash shell: bash
run: npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai deno run: npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai
# A local action lives inside the writable checkout. Execute a root-owned # A local action lives inside the writable checkout. Execute a root-owned
# copy so an agent changing this repository cannot replace a later phase. # copy so an agent changing this repository cannot replace a later phase.
- name: Protect action source - name: Protect action source
+15 -15
View File
@@ -1,11 +1,11 @@
// Runs Claude Code for one profile and returns its final result text. // Runs Claude Code for one profile and returns its final result text.
import { spawn } from "node:child_process";
import { createInterface } from "node:readline"; import { createInterface } from "node:readline";
import type { Readable } from "node:stream"; import type { Readable } from "node:stream";
import { isClaudeAuthFailure } from "./auth.ts"; import { isClaudeAuthFailure } from "./auth.ts";
import type { ClaudeFailureFields } from "./auth.ts"; import type { ClaudeFailureFields } from "./auth.ts";
import { chunks } from "./command.ts"; import { exitCode } from "./child.ts";
import { agentCommand } from "./isolation.ts";
import { isObject } from "./json.ts"; import { isObject } from "./json.ts";
import type { JsonObject } from "./json.ts"; import type { JsonObject } from "./json.ts";
import { AgentAuthError, agentEnvironment } from "./profiles.ts"; import { AgentAuthError, agentEnvironment } from "./profiles.ts";
@@ -20,14 +20,13 @@ type ClaudeResult = ClaudeFailureFields &
// Echoes stderr as it arrives and resolves with all of it. // Echoes stderr as it arrives and resolves with all of it.
async function copyError(stream: Readable): Promise<string> { async function copyError(stream: Readable): Promise<string> {
const decoder = new TextDecoder(); stream.setEncoding("utf8");
let error = ""; let error = "";
for await (const chunk of chunks(stream)) { for await (const text of stream) {
const text = decoder.decode(chunk, { stream: true }); error += String(text);
error += text;
console.error(text); console.error(text);
} }
return error + decoder.decode(); return error;
} }
// Prints the visible text of each streamed message part. // Prints the visible text of each streamed message part.
@@ -101,19 +100,20 @@ export async function runClaude(
"--verbose", "--verbose",
prompt, prompt,
]; ];
const claude = agentCommand(profile, "claude", args, { const claude = spawn("claude", args, {
cwd: WORKSPACE, cwd: WORKSPACE,
env: { ...agentEnvironment(PROCESS_ENV, profile, token), IS_SANDBOX: "1" }, env: { ...agentEnvironment(PROCESS_ENV, profile, token), IS_SANDBOX: "1" },
clearEnv: true, uid: profile.uid,
stdout: "piped", gid: profile.gid,
stderr: "piped", stdio: ["ignore", "pipe", "pipe"],
}).spawn(); });
const status = exitCode(claude);
const errorText = copyError(claude.stderr); const errorText = copyError(claude.stderr);
const result = await readEvents(claude.stdout); const result = await readEvents(claude.stdout);
const [status, error] = await Promise.all([claude.status, errorText]); const [code, error] = await Promise.all([status, errorText]);
const body = checkResult(result, error); const body = checkResult(result, error);
if (!status.success) { if (code !== 0) {
throw new Error(error.trim() || `claude exited with ${status.code}`); throw new Error(error.trim() || `claude exited with ${code}`);
} }
return body; return body;
} }
+55 -45
View File
@@ -1,14 +1,13 @@
// Signs Codex in for one profile, runs it, and returns its result file path. // Signs Codex in for one profile, runs it, and returns its result file path.
import { spawn } from "node:child_process";
import process from "node:process"; import process from "node:process";
import type { Readable } from "node:stream"; import type { Readable } from "node:stream";
import { text } from "node:stream/consumers"; import { finished } from "node:stream/promises";
import { setTimeout as sleep } from "node:timers/promises"; import { setTimeout as sleep } from "node:timers/promises";
import { isCodexAuthFailure, retryInvalidToken } from "./auth.ts"; import { isCodexAuthFailure, retryInvalidToken } from "./auth.ts";
import type { CodexAuthentication } from "./auth.ts"; import type { CodexAuthentication } from "./auth.ts";
import { chunks, endWith, writeChunk } from "./command.ts"; import { echo, exitCode } from "./child.ts";
import type { CommandStatus } from "./command.ts";
import { agentCommand } from "./isolation.ts";
import { AgentAuthError, agentEnvironment, withProfile } from "./profiles.ts"; import { AgentAuthError, agentEnvironment, withProfile } from "./profiles.ts";
import { import {
makeProfileFile, makeProfileFile,
@@ -20,22 +19,21 @@ import {
import type { RuntimeProfile } from "./runtime.ts"; import type { RuntimeProfile } from "./runtime.ts";
interface CodexAttempt { interface CodexAttempt {
status: CommandStatus; code: number;
error: string; error: string;
} }
async function collectText(stream: Readable, append: (chunk: string) => void): Promise<void> { async function collectText(stream: Readable, append: (chunk: string) => void): Promise<void> {
const decoder = new TextDecoder(); stream.setEncoding("utf8");
for await (const chunk of chunks(stream)) { for await (const chunk of stream) {
append(decoder.decode(chunk, { stream: true })); append(String(chunk));
} }
append(decoder.decode());
} }
// Polls every 100 ms until the login prints something, failing if it exits first. // Polls every 100 ms until the login prints something, failing if it exits first.
async function pollForInstructions( async function pollForInstructions(
shownText: () => string, shownText: () => string,
status: Promise<CommandStatus>, status: Promise<number>,
): Promise<void> { ): Promise<void> {
if (shownText().trim() !== "") { if (shownText().trim() !== "") {
return; return;
@@ -51,13 +49,14 @@ async function codexDeviceLogin(
profile: RuntimeProfile, profile: RuntimeProfile,
environment: Record<string, string>, environment: Record<string, string>,
): Promise<void> { ): Promise<void> {
const login = agentCommand(profile, "codex", ["login", "--device-auth"], { const login = spawn("codex", ["login", "--device-auth"], {
cwd: WORKSPACE, cwd: WORKSPACE,
env: environment, env: environment,
clearEnv: true, uid: profile.uid,
stdout: "piped", gid: profile.gid,
stderr: "piped", stdio: ["ignore", "pipe", "pipe"],
}).spawn(); });
const status = exitCode(login);
let shown = ""; let shown = "";
const append = (chunk: string): void => { const append = (chunk: string): void => {
shown += chunk; shown += chunk;
@@ -67,11 +66,11 @@ async function codexDeviceLogin(
collectText(login.stderr, append), collectText(login.stderr, append),
]); ]);
await sleep(3000); await sleep(3000);
await pollForInstructions(() => shown, login.status); await pollForInstructions(() => shown, status);
await postAuthorComment(withProfile(shown, profile)); await postAuthorComment(withProfile(shown, profile));
const [result] = await Promise.all([login.status, drained]); const [code] = await Promise.all([status, drained]);
if (!result.success) { if (code !== 0) {
throw new AgentAuthError(`codex login exited with ${result.code}`); throw new AgentAuthError(`codex login exited with ${code}`);
} }
} }
@@ -80,17 +79,25 @@ async function codexApiLogin(
environment: Record<string, string>, environment: Record<string, string>,
token: string, token: string,
): Promise<void> { ): Promise<void> {
const login = agentCommand(profile, "codex", ["login", "--with-api-key"], { const login = spawn("codex", ["login", "--with-api-key"], {
cwd: WORKSPACE, cwd: WORKSPACE,
env: environment, env: environment,
clearEnv: true, uid: profile.uid,
stdin: "piped", gid: profile.gid,
stderr: "piped", stdio: ["pipe", "inherit", "pipe"],
}).spawn(); });
await endWith(login.stdin, `${token}\n`); const status = exitCode(login);
const [status, error] = await Promise.all([login.status, text(login.stderr)]); login.stdin.end(`${token}\n`);
if (!status.success) { await finished(login.stdin);
throw new Error(error.trim() || `codex login exited with ${status.code}`); let error = "";
const [code] = await Promise.all([
status,
collectText(login.stderr, (chunk) => {
error += chunk;
}),
]);
if (code !== 0) {
throw new Error(error.trim() || `codex login exited with ${code}`);
} }
} }
@@ -103,12 +110,14 @@ async function signIn(
await codexApiLogin(profile, environment, authentication.token); await codexApiLogin(profile, environment, authentication.token);
return; return;
} }
const loggedIn = await agentCommand(profile, "codex", ["login", "status"], { const check = spawn("codex", ["login", "status"], {
cwd: WORKSPACE, cwd: WORKSPACE,
env: environment, env: environment,
clearEnv: true, uid: profile.uid,
}).output(); gid: profile.gid,
if (!loggedIn.success) { stdio: "ignore",
});
if ((await exitCode(check)) !== 0) {
await codexDeviceLogin(profile, environment); await codexDeviceLogin(profile, environment);
} }
} }
@@ -128,22 +137,23 @@ async function execCodex(
outputPath, outputPath,
prompt, prompt,
]; ];
const codex = agentCommand(profile, "codex", args, { const codex = spawn("codex", args, {
cwd: WORKSPACE, cwd: WORKSPACE,
env: environment, env: environment,
clearEnv: true, uid: profile.uid,
stdout: "inherit", gid: profile.gid,
stderr: "piped", stdio: ["ignore", "inherit", "pipe"],
}).spawn(); });
const decoder = new TextDecoder(); const status = exitCode(codex);
codex.stderr.setEncoding("utf8");
let error = ""; let error = "";
for await (const chunk of chunks(codex.stderr)) { for await (const chunk of codex.stderr) {
await writeChunk(process.stderr, chunk); const text = String(chunk);
error += decoder.decode(chunk, { stream: true }); await echo(process.stderr, text);
error += text;
} }
error += decoder.decode();
// Codex echoes the prompt, whose comments may quote auth errors. // Codex echoes the prompt, whose comments may quote auth errors.
return { status: await codex.status, error: error.replaceAll(prompt, "") }; return { code: await status, error: error.replaceAll(prompt, "") };
} }
export async function runCodex( export async function runCodex(
@@ -163,8 +173,8 @@ export async function runCodex(
await runAgentCommand(profile, "codex", ["logout"]); await runAgentCommand(profile, "codex", ["logout"]);
await codexDeviceLogin(profile, environment); await codexDeviceLogin(profile, environment);
}); });
if (!result.status.success) { if (result.code !== 0) {
const failure = new Error(result.error.trim() || `codex exited with ${result.status.code}`); const failure = new Error(result.error.trim() || `codex exited with ${result.code}`);
if (isCodexAuthFailure(result.error)) { if (isCodexAuthFailure(result.error)) {
throw new AgentAuthError("codex sign-in failed", { cause: failure }); throw new AgentAuthError("codex sign-in failed", { cause: failure });
} }
+15 -20
View File
@@ -1,8 +1,8 @@
// Runs OpenCode for one profile and returns its final visible text. // Runs OpenCode for one profile and returns its final visible text.
import { spawn } from "node:child_process";
import process from "node:process"; import process from "node:process";
import { chunks, writeChunk } from "./command.ts"; import { echo, exitCode } from "./child.ts";
import { agentCommand } from "./isolation.ts";
import { import {
collectOpenCodeOutput, collectOpenCodeOutput,
openCodeArgs, openCodeArgs,
@@ -18,29 +18,24 @@ export async function runOpenCode(
token: string, token: string,
prompt: string, prompt: string,
): Promise<string> { ): Promise<string> {
const child = agentCommand(profile, "opencode", openCodeArgs(profile.model, prompt), { const child = spawn("opencode", openCodeArgs(profile.model, prompt), {
cwd: WORKSPACE, cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token), env: agentEnvironment(PROCESS_ENV, profile, token),
clearEnv: true, uid: profile.uid,
stdout: "piped", gid: profile.gid,
stderr: "piped", stdio: ["ignore", "pipe", "pipe"],
}).spawn(); });
const status = exitCode(child);
const collected = collectOpenCodeOutput( const collected = collectOpenCodeOutput(
chunks(child.stdout), child.stdout,
chunks(child.stderr), child.stderr,
async (chunk) => { (chunk) => echo(process.stdout, chunk),
await writeChunk(process.stdout, chunk); (chunk) => echo(process.stderr, chunk),
},
async (chunk) => {
await writeChunk(process.stderr, chunk);
},
); );
const [status, streams] = await Promise.all([child.status, collected]); const [code, streams] = await Promise.all([status, collected]);
const error = streams.stderr.trim(); const error = streams.stderr.trim();
if (!status.success) { if (code !== 0) {
throw ( throw openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${code}`);
openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${status.code}`)
);
} }
return parseOpenCodeEvents(streams.stdout); return parseOpenCodeEvents(streams.stdout);
} }
+5 -5
View File
@@ -17,7 +17,7 @@ test("retries once after an invalid refresh token", async () => {
const result = await retryInvalidToken( const result = await retryInvalidToken(
() => () =>
Promise.resolve({ Promise.resolve({
status: { success: false }, code: 1,
error: attempts++ === 0 ? "ERROR: invalid_refresh_token" : "still failed", error: attempts++ === 0 ? "ERROR: invalid_refresh_token" : "still failed",
}), }),
() => { () => {
@@ -40,7 +40,7 @@ test("does not retry an unrelated failure", async () => {
() => { () => {
attempts++; attempts++;
return Promise.resolve({ return Promise.resolve({
status: { success: false }, code: 1,
error: "rate limited", error: "rate limited",
}); });
}, },
@@ -86,7 +86,7 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => {
const result = await retryInvalidToken( const result = await retryInvalidToken(
() => () =>
Promise.resolve({ Promise.resolve({
status: { success: attempts++ > 0 }, code: attempts++ > 0 ? 0 : 1,
error: attempts === 1 ? REVOKED_CODEX.join("\n") : "", error: attempts === 1 ? REVOKED_CODEX.join("\n") : "",
}), }),
() => { () => {
@@ -95,9 +95,9 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => {
}, },
); );
expect({ relogins, success: result.status.success }).toStrictEqual({ expect({ relogins, code: result.code }).toStrictEqual({
relogins: 1, relogins: 1,
success: true, code: 0,
}); });
}); });
+3 -3
View File
@@ -48,12 +48,12 @@ export function isClaudeAuthFailure(result: ClaudeFailureFields): boolean {
); );
} }
export async function retryInvalidToken<T extends { status: { success: boolean }; error: string }>( export async function retryInvalidToken<T extends { code: number; error: string }>(
attempt: () => Promise<T>, attempt: () => Promise<T>,
relogin: () => Promise<void>, relogin: () => Promise<void>,
): Promise<T> { ): Promise<T> {
let result = await attempt(); let result = await attempt();
if (!result.status.success && isCodexAuthFailure(result.error)) { if (result.code !== 0 && isCodexAuthFailure(result.error)) {
await relogin(); await relogin();
result = await attempt(); result = await attempt();
} }
@@ -116,7 +116,7 @@ export async function stageCodexAuth(
return stagedPath; return stagedPath;
} }
// Creates an empty private file under a fresh name, as Deno.makeTempFile did. // Creates an empty private file under a fresh name.
async function makeTempFile(directory: string): Promise<string> { async function makeTempFile(directory: string): Promise<string> {
const path = join(directory, `.tmp-${randomUUID()}`); const path = join(directory, `.tmp-${randomUUID()}`);
const handle = await open(path, "wx", 0o600); const handle = await open(path, "wx", 0o600);
+35
View File
@@ -0,0 +1,35 @@
import { spawn } from "node:child_process";
import { expect, test } from "vite-plus/test";
import { isNotFound } from "./errors.ts";
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);
});
test("reports a signal as 128 plus its number", async () => {
const child = spawn("/bin/sh", ["-c", "kill -KILL $$"], { stdio: "ignore" });
expect(await exitCode(child)).toBe(137);
});
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 started = Date.now();
expect(await exitCode(child)).toBe(5);
expect(Date.now() - started).toBeLessThan(1500);
child.stdout.destroy();
child.stderr.destroy();
});
test("rejects when the command does not exist", async () => {
const child = spawn("/nonexistent/command", [], { stdio: "ignore" });
const code = exitCode(child);
// The rejection must stay unhandled-safe until it is awaited.
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
await expect(code).rejects.toSatisfy(isNotFound);
});
+30
View File
@@ -0,0 +1,30 @@
// What the child process call sites share: the exit status, read right after
// `spawn` so the exit event cannot pass unseen while the caller reads the
// streams, and echoing output without outrunning a slow reader.
import type { ChildProcess } from "node:child_process";
import { once } from "node:events";
import { constants } from "node:os";
import type { Writable } from "node:stream";
// Resolves once the process exits, before its streams close, with its exit
// code, or with 128 plus the signal number if a signal killed it, as a shell
// reports. A process that fails to start rejects with the spawn error.
export function exitCode(child: ChildProcess): Promise<number> {
const code = new Promise<number>((resolve, reject) => {
child.once("error", reject);
child.once("exit", (exit, signal) => {
resolve(exit ?? (signal === null ? 1 : 128 + constants.signals[signal]));
});
});
// Callers await it after draining the streams; a failed start must not be
// reported as an unhandled rejection before then.
code.catch(() => {});
return code;
}
// 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> {
if (!stream.write(chunk)) {
await once(stream, "drain");
}
}
-160
View File
@@ -1,160 +0,0 @@
// A child process with the call shapes the runner was reviewed with: `output()`
// collects both streams, `spawn()` hands them out, and `clearEnv` passes only
// the given environment. Streams default as Deno's did: stdin is inherited,
// stdout and stderr are piped for `output()` and inherited for `spawn()`.
import { spawn as spawnProcess } from "node:child_process";
import type { ChildProcess, StdioOptions } from "node:child_process";
import { Buffer } from "node:buffer";
import { constants } from "node:os";
import process from "node:process";
import type { Readable, Writable } from "node:stream";
import { buffer } from "node:stream/consumers";
import { finished } from "node:stream/promises";
export type StdioMode = "inherit" | "piped" | "null";
export interface CommandOptions {
args?: string[];
cwd?: string;
env?: Record<string, string>;
clearEnv?: boolean;
uid?: number;
gid?: number;
stdin?: StdioMode;
stdout?: StdioMode;
stderr?: StdioMode;
}
export interface CommandStatus {
success: boolean;
code: number;
}
export interface CommandOutput extends CommandStatus {
stdout: Uint8Array;
stderr: Uint8Array;
}
export interface CommandChild {
status: Promise<CommandStatus>;
stdin: Writable;
stdout: Readable;
stderr: Readable;
}
const STDIO: Record<StdioMode, "inherit" | "pipe" | "ignore"> = {
inherit: "inherit",
piped: "pipe",
null: "ignore",
};
function signalNumber(signal: NodeJS.Signals): number {
return constants.signals[signal];
}
function startProcess(
command: string,
options: CommandOptions,
outputDefault: StdioMode,
): ChildProcess {
const stdio: StdioOptions = [
STDIO[options.stdin ?? "inherit"],
STDIO[options.stdout ?? outputDefault],
STDIO[options.stderr ?? outputDefault],
];
const environment = options.clearEnv === true ? options.env : { ...process.env, ...options.env };
return spawnProcess(command, options.args ?? [], {
stdio,
...(options.cwd !== undefined && { cwd: options.cwd }),
...(environment !== undefined && { env: environment }),
...(options.uid !== undefined && { uid: options.uid }),
...(options.gid !== undefined && { gid: options.gid }),
});
}
// Settles when the process exits, before its streams close, as Deno's did. A
// signal-killed process reports 128 plus the signal number, as a shell does.
function waitForStatus(child: ChildProcess): Promise<CommandStatus> {
return new Promise((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code, signal) => {
const exitCode = code ?? (signal === null ? 1 : 128 + signalNumber(signal));
resolve({ success: exitCode === 0, code: exitCode });
});
});
}
function piped<T>(stream: T | null, name: string): T {
if (stream === null) {
throw new TypeError(`${name} is not piped`);
}
return stream;
}
function collect(stream: Readable | null): Promise<Uint8Array> {
return stream === null ? Promise.resolve(new Uint8Array()) : buffer(stream);
}
// A stream's chunks as bytes, whether or not it has an encoding set.
export async function* chunks(stream: Readable): AsyncGenerator<Uint8Array> {
for await (const chunk of stream) {
const value: unknown = chunk;
if (value instanceof Uint8Array) {
yield value;
} else if (typeof value === "string") {
yield Buffer.from(value);
}
}
}
export function spawn(command: string, options: CommandOptions = {}): CommandChild {
const child = startProcess(command, options, "inherit");
const status = waitForStatus(child);
// A failed start rejects `status`; the caller sees it once it awaits that.
status.catch(() => {});
return {
status,
get stdin(): Writable {
return piped(child.stdin, "stdin");
},
get stdout(): Readable {
return piped(child.stdout, "stdout");
},
get stderr(): Readable {
return piped(child.stderr, "stderr");
},
};
}
export async function output(
command: string,
options: CommandOptions = {},
): Promise<CommandOutput> {
const child = startProcess(command, options, "piped");
const [status, stdout, stderr] = await Promise.all([
waitForStatus(child),
collect(child.stdout),
collect(child.stderr),
]);
return { ...status, stdout, stderr };
}
// Writes one chunk and settles once the stream has taken it, as Deno's
// `Deno.stdout.write` did.
export async function writeChunk(stream: Writable, chunk: Uint8Array): Promise<void> {
await new Promise<void>((resolve, reject) => {
stream.write(chunk, (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
// Writes the last chunk and settles once the stream has flushed and closed.
export async function endWith(stream: Writable, chunk: string): Promise<void> {
stream.end(chunk);
await finished(stream);
}
+1 -2
View File
@@ -1,5 +1,4 @@
// Node reports a missing file as ENOENT and a missing process as ESRCH, where // A missing file or a missing process: Node reports them as ENOENT and ESRCH.
// Deno threw `Deno.errors.NotFound` for both.
export function isNotFound(error: unknown): boolean { export function isNotFound(error: unknown): boolean {
if (!(error instanceof Error) || !("code" in error)) { if (!(error instanceof Error) || !("code" in error)) {
return false; return false;
+34 -34
View File
@@ -1,14 +1,11 @@
import { spawnSync } from "node:child_process";
import type { SpawnSyncReturns } from "node:child_process";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
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 process from "node:process";
import { isNotFound } from "./errors.ts"; import { isNotFound } from "./errors.ts";
import { import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from "./isolation.ts";
agentCommand,
isRunnableProfileProcess,
killProfileProcesses,
readProfileFile,
} from "./isolation.ts";
import type { RuntimeProfile } from "./runtime.ts"; import type { RuntimeProfile } from "./runtime.ts";
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "./testing.ts"; import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "./testing.ts";
@@ -22,6 +19,23 @@ const profile = (uid: number, home: string): RuntimeProfile => ({
tokenFile: null, tokenFile: null,
}); });
// Runs a command as the profile's user from the root directory. Without `env`
// it inherits this process's environment.
function runAs(
selected: RuntimeProfile,
command: string,
args: string[],
env?: Record<string, string>,
): SpawnSyncReturns<string> {
return spawnSync(command, args, {
cwd: "/",
uid: selected.uid,
gid: selected.gid,
encoding: "utf8",
...(env !== undefined && { env }),
});
}
test("zombie process entries are already quiescent", () => { test("zombie process entries are already quiescent", () => {
const status = "State:\tZ (zombie)\nUid:\t20010\t20010\t20010\t20010\n"; const status = "State:\tZ (zombie)\nUid:\t20010\t20010\t20010\t20010\n";
expect(isRunnableProfileProcess(status, 20_010)).toBe(false); expect(isRunnableProfileProcess(status, 20_010)).toBe(false);
@@ -60,20 +74,14 @@ test.skipIf(!IS_ROOT)("a profile process can read only its own private files", a
await writeFile(`${root}/reviewer`, "root", { mode: 0o600 }); await writeFile(`${root}/reviewer`, "root", { mode: 0o600 });
const selected = profile(20_000, homeA); const selected = profile(20_000, homeA);
const own = await agentCommand(selected, "/bin/cat", [`${homeA}/token`], { const own = runAs(selected, "/bin/cat", [`${homeA}/token`]);
cwd: "/", const sibling = runAs(selected, "/bin/cat", [`${homeB}/token`]);
}).output(); const reviewer = runAs(selected, "/bin/cat", [`${root}/reviewer`]);
const sibling = await agentCommand(selected, "/bin/cat", [`${homeB}/token`], {
cwd: "/",
}).output();
const reviewer = await agentCommand(selected, "/bin/cat", [`${root}/reviewer`], {
cwd: "/",
}).output();
expect(new TextDecoder().decode(own.stdout)).toBe("own"); expect(own.stdout).toBe("own");
expect(own.success).toBe(true); expect(own.status).toBe(0);
expect(sibling.success).toBe(false); expect(sibling.status).not.toBe(0);
expect(reviewer.success).toBe(false); expect(reviewer.status).not.toBe(0);
} finally { } finally {
await rm(root, { recursive: true }); await rm(root, { recursive: true });
} }
@@ -81,13 +89,8 @@ test.skipIf(!IS_ROOT)("a profile process can read only its own private files", a
test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async () => { test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async () => {
const selected = profile(20_010, "/tmp"); const selected = profile(20_010, "/tmp");
const launched = await agentCommand( const launched = runAs(selected, "/bin/sh", ["-c", "sleep 60 >/dev/null 2>&1 & echo $!"]);
selected, const pid = Number(launched.stdout.trim());
"/bin/sh",
["-c", "sleep 60 >/dev/null 2>&1 & echo $!"],
{ cwd: "/" },
).output();
const pid = Number(new TextDecoder().decode(launched.stdout).trim());
expect(Number.isInteger(pid)).toBe(true); expect(Number.isInteger(pid)).toBe(true);
await killProfileProcesses(selected.uid); await killProfileProcesses(selected.uid);
@@ -99,14 +102,11 @@ test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async ()
expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true); expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true);
}); });
test.skipIf(!IS_ROOT)("the agent process cannot read its coordinator environment", async () => { 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 = await agentCommand(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], { const result = runAs(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], {
cwd: "/", PARENT_SECRET: "must-not-cross-boundary",
env: { PARENT_SECRET: "must-not-cross-boundary" }, });
clearEnv: true,
stderr: "piped",
}).output();
expect(result.success).toBe(false); expect(result.status).not.toBe(0);
}); });
-22
View File
@@ -2,31 +2,9 @@ import { lstat, readdir, readFile } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises"; import { setTimeout as sleep } from "node:timers/promises";
import { output, spawn } from "./command.ts";
import type { CommandChild, CommandOptions, CommandOutput } from "./command.ts";
import { isNotFound } from "./errors.ts"; import { isNotFound } from "./errors.ts";
import type { RuntimeProfile } from "./runtime.ts"; import type { RuntimeProfile } from "./runtime.ts";
export type AgentCommandOptions = Omit<CommandOptions, "args" | "uid" | "gid">;
export interface AgentCommand {
output(): Promise<CommandOutput>;
spawn(): CommandChild;
}
export function agentCommand(
profile: RuntimeProfile,
command: string,
args: string[],
options: AgentCommandOptions = {},
): AgentCommand {
const full: CommandOptions = { ...options, args, uid: profile.uid, gid: profile.gid };
return {
output: () => output(command, full),
spawn: () => spawn(command, full),
};
}
export function isRunnableProfileProcess(status: string, uid: number): boolean { export function isRunnableProfileProcess(status: string, uid: number): boolean {
const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1]; const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1];
const state = /^State:\s+(\w)/mu.exec(status)?.[1]; const state = /^State:\s+(\w)/mu.exec(status)?.[1];
+9 -5
View File
@@ -1,9 +1,10 @@
// The agent phase's environment and the helpers every agent runner shares. It // The agent phase's environment and the helpers every agent runner shares. It
// is read once, when the runner starts, as the single-file runner did. // is read once, when the runner starts, as the single-file runner did.
import { spawn } from "node:child_process";
import { chmod, chown, writeFile } from "node:fs/promises"; import { chmod, chown, writeFile } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { agentCommand } from "./isolation.ts"; import { exitCode } from "./child.ts";
import { agentEnvironment, sanitizedEnvironment } from "./profiles.ts"; import { agentEnvironment, sanitizedEnvironment } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts"; import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeProfile } from "./runtime.ts"; import type { RuntimeProfile } from "./runtime.ts";
@@ -60,12 +61,15 @@ export async function runAgentCommand(
args: string[], args: string[],
token?: string, token?: string,
): Promise<void> { ): Promise<void> {
const { success, code } = await agentCommand(profile, command, args, { const child = spawn(command, args, {
cwd: WORKSPACE, cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token), env: agentEnvironment(PROCESS_ENV, profile, token),
clearEnv: true, uid: profile.uid,
}).output(); gid: profile.gid,
if (!success) { stdio: "ignore",
});
const code = await exitCode(child);
if (code !== 0) {
throw new Error(`${command} ${args[0]} exited with ${code}`); throw new Error(`${command} ${args[0]} exited with ${code}`);
} }
} }
+4 -3
View File
@@ -1,11 +1,12 @@
// Runs one ordered agent profile. Credential decryption and reviewer posting // Runs one ordered agent profile. Credential decryption and reviewer posting
// live in separate processes so neither privileged secret reaches this runner. // live in separate processes so neither privileged secret reaches this runner.
import { spawn } from "node:child_process";
import { chmod, readFile, writeFile } from "node:fs/promises"; import { chmod, readFile, writeFile } from "node:fs/promises";
import { codexAuthentication, persistCodexAuth, stageCodexAuth } from "./auth.ts"; import { codexAuthentication, persistCodexAuth, stageCodexAuth } from "./auth.ts";
import { runAgent } from "./agents.ts"; import { runAgent } from "./agents.ts";
import type { AgentAnswer } from "./agents.ts"; import type { AgentAnswer } from "./agents.ts";
import { output } from "./command.ts"; import { exitCode } from "./child.ts";
import { killProfileProcesses, readProfileFile } from "./isolation.ts"; import { killProfileProcesses, readProfileFile } from "./isolation.ts";
import { isObject } from "./json.ts"; import { isObject } from "./json.ts";
import { runProfiles } from "./profiles.ts"; import { runProfiles } from "./profiles.ts";
@@ -43,8 +44,8 @@ const CODEX_AUTH = "/root/.codex/auth.json";
const SKILLS_INSTALL = "skills add obra/superpowers"; const SKILLS_INSTALL = "skills add obra/superpowers";
async function runRoot(command: string, args: string[]): Promise<void> { async function runRoot(command: string, args: string[]): Promise<void> {
const { success, code } = await output(command, { args, env: PROCESS_ENV, clearEnv: true }); const code = await exitCode(spawn(command, args, { env: PROCESS_ENV, stdio: "ignore" }));
if (!success) { if (code !== 0) {
throw new Error(`${command} ${args[0]} exited with ${code}`); throw new Error(`${command} ${args[0]} exited with ${code}`);
} }
} }
+22 -25
View File
@@ -1,10 +1,10 @@
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 process from "node:process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { assert, expect, test } from "vite-plus/test"; import { assert, expect, test } from "vite-plus/test";
import { output } from "./command.ts";
import { prepareRuntime } from "./runtime.ts"; import { prepareRuntime } from "./runtime.ts";
import type { RuntimeProfile } from "./runtime.ts"; import type { RuntimeProfile } from "./runtime.ts";
import { IS_ROOT, makeTempDir } from "./testing.ts"; import { IS_ROOT, makeTempDir } from "./testing.ts";
@@ -13,14 +13,9 @@ const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA"; const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
// Runs `command` as the profile's user, from the root directory. // Runs `command` as the profile's user, from the root directory.
async function runAs(profile: RuntimeProfile, command: string, path: string): Promise<boolean> { function runAs(profile: RuntimeProfile, command: string, path: string): boolean {
const result = await output(command, { const result = spawnSync(command, [path], { cwd: "/", uid: profile.uid, gid: profile.gid });
args: [path], return result.status === 0;
cwd: "/",
uid: profile.uid,
gid: profile.gid,
});
return result.success;
} }
async function expectPrivate(path: string, uid: number, mode: number): Promise<void> { async function expectPrivate(path: string, uid: number, mode: number): Promise<void> {
@@ -58,16 +53,16 @@ test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credenti
expect((await stat(codex.home)).uid).toStrictEqual(codex.uid); expect((await stat(codex.home)).uid).toStrictEqual(codex.uid);
expect(codex.tokenFile).toBe(null); expect(codex.tokenFile).toBe(null);
const ownToken = await output("/bin/cat", { const ownToken = spawnSync("/bin/cat", [claude.tokenFile], {
args: [claude.tokenFile],
cwd: "/", cwd: "/",
uid: claude.uid, uid: claude.uid,
gid: claude.gid, gid: claude.gid,
encoding: "utf8",
}); });
expect(ownToken.success).toBe(true); expect(ownToken.status).toBe(0);
expect(new TextDecoder().decode(ownToken.stdout)).toBe("secret-token"); expect(ownToken.stdout).toBe("secret-token");
expect(await runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false); expect(runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false);
expect(await runAs(claude, "/bin/ls", codex.home)).toBe(false); expect(runAs(claude, "/bin/ls", codex.home)).toBe(false);
} finally { } finally {
await rm(root, { recursive: true }); await rm(root, { recursive: true });
await rm(action, { recursive: true }); await rm(action, { recursive: true });
@@ -85,18 +80,20 @@ test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fail
profiles: [{ type: "claude", token: ENCRYPTED }], profiles: [{ type: "claude", token: ENCRYPTED }],
}), }),
); );
const result = await output(process.execPath, { const result = spawnSync(
args: [fileURLToPath(new URL("prepare.ts", import.meta.url))], process.execPath,
env: { [fileURLToPath(new URL("prepare.ts", import.meta.url))],
ACTION_PATH: action, {
RUNNER_TEMP: root, env: {
BOT_AGENTS_MASTER_KEY: MASTER_KEY, ACTION_PATH: action,
GITHUB_OUTPUT: action, RUNNER_TEMP: root,
BOT_AGENTS_MASTER_KEY: MASTER_KEY,
GITHUB_OUTPUT: action,
},
}, },
clearEnv: true, );
});
expect(result.success).toBe(false); expect(result.status).not.toBe(0);
expect(await readdir(root)).toStrictEqual([]); expect(await readdir(root)).toStrictEqual([]);
} finally { } finally {
await rm(root, { recursive: true }); await rm(root, { recursive: true });
+7 -9
View File
@@ -1,10 +1,9 @@
import { spawnSync } from "node:child_process";
import process from "node:process"; import process from "node:process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { output } from "../src/command.ts";
// Each phase runs as `node src/<phase>.ts` with no build step. Node loads the // Each phase runs as `node src/<phase>.ts` with no build step. Node loads the
// whole import graph, stripping types, before the phase reads its first // whole import graph, stripping types, before the phase reads its first
// variable, so failing on that variable proves every module loads. // variable, so failing on that variable proves every module loads.
@@ -14,14 +13,13 @@ const PHASES: [phase: string, firstVariable: string][] = [
["post", "BOT_AGENTS_MANIFEST"], ["post", "BOT_AGENTS_MANIFEST"],
]; ];
test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => { test.each(PHASES)("Node loads the %s phase", (phase, firstVariable) => {
const path = fileURLToPath(new URL(`../src/${phase}.ts`, import.meta.url)); const path = fileURLToPath(new URL(`../src/${phase}.ts`, import.meta.url));
const result = await output(process.execPath, { const result = spawnSync(process.execPath, [path], {
args: [path],
env: { PATH: process.env.PATH ?? "" }, env: { PATH: process.env.PATH ?? "" },
clearEnv: true, stdio: ["ignore", "pipe", "pipe"],
stdin: "null", encoding: "utf8",
}); });
expect(result.success).toBe(false); expect(result.status).not.toBe(0);
expect(new TextDecoder().decode(result.stderr)).toContain(`${firstVariable} is not set`); expect(result.stderr).toContain(`${firstVariable} is not set`);
}); });
+15 -6
View File
@@ -1,10 +1,10 @@
import { spawnSync } from "node:child_process";
import { readdir, readFile, rm, stat } from "node:fs/promises"; import { readdir, readFile, rm, stat } from "node:fs/promises";
import process from "node:process"; 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 { output } from "../src/command.ts";
import { isNotFound } from "../src/errors.ts"; import { isNotFound } from "../src/errors.ts";
import { isMapping } from "../src/json.ts"; import { isMapping } from "../src/json.ts";
import type { JsonObject } from "../src/json.ts"; import type { JsonObject } from "../src/json.ts";
@@ -76,12 +76,16 @@ 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 output("/bin/bash", { const result = spawnSync("/bin/bash", ["scripts/protect-source.sh"], {
args: ["scripts/protect-source.sh"], env: {
env: { ACTION_PATH: process.cwd(), GITHUB_OUTPUT: outputFile, RUNNER_TEMP: temp }, ...process.env,
stderr: "piped", ACTION_PATH: process.cwd(),
GITHUB_OUTPUT: outputFile,
RUNNER_TEMP: temp,
},
encoding: "utf8",
}); });
assert(result.success, new TextDecoder().decode(result.stderr)); assert(result.status === 0, result.stderr);
const path = (await readFile(outputFile, "utf8")).trim().slice(5); const path = (await readFile(outputFile, "utf8")).trim().slice(5);
const files = [ const files = [
@@ -160,6 +164,11 @@ test("installs the OpenCode CLI", async () => {
assert(action.includes("opencode-ai")); assert(action.includes("opencode-ai"));
}); });
test("does not install Deno", async () => {
const action = await readFile("action.yml", "utf8");
expect(action).not.toMatch(/\bdeno\b/iu);
});
test("profile preparation does not receive provider credentials", async () => { test("profile preparation does not receive provider credentials", async () => {
const runner = await readFile("src/run.ts", "utf8"); const runner = await readFile("src/run.ts", "utf8");
assert(runner.includes("await prepareProfile(profile);")); assert(runner.includes("await prepareProfile(profile);"));