Spawn child processes directly and drop the Deno.Command shim

This commit is contained in:
Danny Kim
2026-10-17 23:15:39 +09:00
parent 5b568f9ea6
commit d053e2b5a3
13 changed files with 255 additions and 361 deletions
+83 -82
View File
@@ -1,10 +1,12 @@
// Runs one ordered agent profile. Credential decryption and reviewer posting
// live in separate processes so neither privileged secret reaches this runner.
import { spawn } from "node:child_process";
import { chmod, chown, readFile, writeFile } from "node:fs/promises";
import process from "node:process";
import { createInterface } from "node:readline";
import type { Readable } from "node:stream";
import { text as readText } from "node:stream/consumers";
import { finished } from "node:stream/promises";
import { setTimeout as sleep } from "node:timers/promises";
import {
@@ -16,9 +18,8 @@ import {
stageCodexAuth,
} from "./auth.ts";
import type { ClaudeFailureFields, CodexAuthentication } from "./auth.ts";
import { chunks, endWith, output, writeChunk } from "./command.ts";
import type { CommandStatus } from "./command.ts";
import { agentCommand, killProfileProcesses, readProfileFile } from "./isolation.ts";
import { echo, exitCode } from "./child.ts";
import { killProfileProcesses, readProfileFile } from "./isolation.ts";
import { isObject } from "./json.ts";
import {
collectOpenCodeOutput,
@@ -98,8 +99,8 @@ async function postAuthorComment(body: string): Promise<void> {
}
async function runRoot(command: string, args: string[]): Promise<void> {
const { success, code } = await output(command, { args, env: PROCESS_ENV, clearEnv: true });
if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`);
const code = await exitCode(spawn(command, args, { env: PROCESS_ENV, stdio: "ignore" }));
if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`);
}
async function configureGitAuthor(): Promise<void> {
@@ -122,12 +123,15 @@ async function runAgentCommand(
args: string[],
token?: string,
): Promise<void> {
const { success, code } = await agentCommand(profile, command, args, {
const child = spawn(command, args, {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
clearEnv: true,
}).output();
if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`);
uid: profile.uid,
gid: profile.gid,
stdio: "ignore",
});
const code = await exitCode(child);
if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`);
}
async function prepareProfile(profile: RuntimeProfile): Promise<void> {
@@ -189,8 +193,7 @@ async function renderPrompt(
}
async function runClaude(profile: RuntimeProfile, token: string, prompt: string): Promise<string> {
const claude = agentCommand(
profile,
const claude = spawn(
"claude",
[
"--print",
@@ -208,21 +211,21 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
...agentEnvironment(PROCESS_ENV, profile, token),
IS_SANDBOX: "1",
},
clearEnv: true,
stdout: "piped",
stderr: "piped",
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
},
).spawn();
);
const status = exitCode(claude);
let result: ClaudeResult | undefined;
let error = "";
const copyError = (async (): Promise<void> => {
const decoder = new TextDecoder();
for await (const chunk of chunks(claude.stderr)) {
const part = decoder.decode(chunk, { stream: true });
claude.stderr.setEncoding("utf8");
for await (const chunk of claude.stderr) {
const part = String(chunk);
error += part;
console.error(part);
}
error += decoder.decode();
})();
for await (const line of createInterface({ input: claude.stdout, crlfDelay: Infinity })) {
if (line !== "") {
@@ -250,7 +253,7 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
}
}
}
const [status] = await Promise.all([claude.status, copyError]);
const [code] = await Promise.all([status, copyError]);
if (result?.is_error === true) {
const failure = new Error(result.result ?? `claude ended with ${result.subtype}`);
if (isClaudeAuthFailure(result)) {
@@ -261,8 +264,8 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
if (result?.result === undefined) {
throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`);
}
if (!status.success) {
throw new Error(error.trim() || `claude exited with ${status.code}`);
if (code !== 0) {
throw new Error(error.trim() || `claude exited with ${code}`);
}
return result.result;
}
@@ -270,7 +273,7 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
// Polls every 100 ms until the login prints something, failing if it exits first.
async function pollForInstructions(
shownText: () => string,
status: Promise<CommandStatus>,
status: Promise<number>,
): Promise<void> {
if (shownText().trim() !== "") {
return;
@@ -286,29 +289,28 @@ async function codexDeviceLogin(
profile: RuntimeProfile,
environment: Record<string, string>,
): Promise<void> {
const login = agentCommand(profile, "codex", ["login", "--device-auth"], {
const login = spawn("codex", ["login", "--device-auth"], {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
stdout: "piped",
stderr: "piped",
}).spawn();
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
});
const status = exitCode(login);
let shown = "";
const collect = async (stream: Readable): Promise<void> => {
const decoder = new TextDecoder();
for await (const chunk of chunks(stream)) {
shown += decoder.decode(chunk, { stream: true });
stream.setEncoding("utf8");
for await (const chunk of stream) {
shown += String(chunk);
}
shown += decoder.decode();
};
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
const status = login.status;
await sleep(3000);
await pollForInstructions(() => shown, status);
await postAuthorComment(withProfile(shown, profile));
const [result] = await Promise.all([status, drained]);
if (!result.success) {
throw new AgentAuthError(`codex login exited with ${result.code}`);
const [code] = await Promise.all([status, drained]);
if (code !== 0) {
throw new AgentAuthError(`codex login exited with ${code}`);
}
}
@@ -317,17 +319,19 @@ async function codexApiLogin(
environment: Record<string, string>,
token: string,
): Promise<void> {
const login = agentCommand(profile, "codex", ["login", "--with-api-key"], {
const login = spawn("codex", ["login", "--with-api-key"], {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
stdin: "piped",
stderr: "piped",
}).spawn();
await endWith(login.stdin, `${token}\n`);
const [status, error] = await Promise.all([login.status, readText(login.stderr)]);
if (!status.success) {
throw new Error(error.trim() || `codex login exited with ${status.code}`);
uid: profile.uid,
gid: profile.gid,
stdio: ["pipe", "inherit", "pipe"],
});
const status = exitCode(login);
login.stdin.end(`${token}\n`);
await finished(login.stdin);
const [code, error] = await Promise.all([status, readText(login.stderr)]);
if (code !== 0) {
throw new Error(error.trim() || `codex login exited with ${code}`);
}
}
@@ -349,17 +353,18 @@ async function runCodex(
if (authentication.kind === "api-key") {
await codexApiLogin(profile, environment, authentication.token);
} else {
const loggedIn = await agentCommand(profile, "codex", ["login", "status"], {
const check = spawn("codex", ["login", "status"], {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
}).output();
if (!loggedIn.success) await codexDeviceLogin(profile, environment);
uid: profile.uid,
gid: profile.gid,
stdio: "ignore",
});
if ((await exitCode(check)) !== 0) await codexDeviceLogin(profile, environment);
}
const outputPath = await makeProfileFile(profile, "result.md");
const attempt = async (): Promise<{ status: CommandStatus; error: string }> => {
const codex = agentCommand(
profile,
const attempt = async (): Promise<{ code: number; error: string }> => {
const codex = spawn(
"codex",
[
"exec",
@@ -373,20 +378,21 @@ async function runCodex(
{
cwd: WORKSPACE,
env: environment,
clearEnv: true,
stdout: "inherit",
stderr: "piped",
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "inherit", "pipe"],
},
).spawn();
const decoder = new TextDecoder();
);
const status = exitCode(codex);
codex.stderr.setEncoding("utf8");
let error = "";
for await (const chunk of chunks(codex.stderr)) {
await writeChunk(process.stderr, chunk);
error += decoder.decode(chunk, { stream: true });
for await (const chunk of codex.stderr) {
const part = String(chunk);
await echo(process.stderr, part);
error += part;
}
error += decoder.decode();
// 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, "") };
};
const result =
authentication.kind === "api-key"
@@ -395,8 +401,8 @@ async function runCodex(
await runAgentCommand(profile, "codex", ["logout"]);
await codexDeviceLogin(profile, environment);
});
if (!result.status.success) {
const failure = new Error(result.error.trim() || `codex exited with ${result.status.code}`);
if (result.code !== 0) {
const failure = new Error(result.error.trim() || `codex exited with ${result.code}`);
if (isCodexAuthFailure(result.error)) {
throw new AgentAuthError("codex sign-in failed", { cause: failure });
}
@@ -410,29 +416,24 @@ async function runOpenCode(
token: string,
prompt: string,
): Promise<string> {
const child = agentCommand(profile, "opencode", openCodeArgs(profile.model, prompt), {
const child = spawn("opencode", openCodeArgs(profile.model, prompt), {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
clearEnv: true,
stdout: "piped",
stderr: "piped",
}).spawn();
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
});
const status = exitCode(child);
const collected = collectOpenCodeOutput(
chunks(child.stdout),
chunks(child.stderr),
async (chunk) => {
await writeChunk(process.stdout, chunk);
},
async (chunk) => {
await writeChunk(process.stderr, chunk);
},
child.stdout,
child.stderr,
(chunk) => echo(process.stdout, chunk),
(chunk) => echo(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();
if (!status.success) {
throw (
openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${status.code}`)
);
if (code !== 0) {
throw openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${code}`);
}
return parseOpenCodeEvents(streams.stdout);
}