refactor: deduplicate spawn-safe into @uncaged/nerve-core #249

Merged
xiaomo merged 1 commits from fix/247-spawn-safe-dedup into main 2026-04-29 09:17:32 +00:00
10 changed files with 15 additions and 385 deletions
+1 -3
View File
@@ -1,7 +1,5 @@
import type { AgentConfig, AgentFn, WorkflowContext } from "@uncaged/nerve-core";
import { type Result, ok } from "@uncaged/nerve-core";
import { type SpawnEnv, type SpawnError, spawnSafe } from "./spawn-safe.js";
import { type Result, type SpawnEnv, type SpawnError, ok, spawnSafe } from "@uncaged/nerve-core";
export type CursorAgentMode = "plan" | "ask" | "default";
-186
View File
@@ -1,186 +0,0 @@
import { spawn } from "node:child_process";
import { homedir } from "node:os";
import { join } from "node:path";
import { type Result, err, ok } from "@uncaged/nerve-core";
/** Compatible with `process.env` for `child_process.spawn`. */
export type SpawnEnv = Record<string, string | undefined>;
export type SpawnResult = {
stdout: string;
stderr: string;
exitCode: number;
/** OS signal name (e.g. `"SIGTERM"`) when terminated by signal; otherwise `null`. */
signal: string | null;
};
export type SpawnError =
| {
kind: "non_zero_exit";
stdout: string;
stderr: string;
exitCode: number;
signal: string | null;
}
| { kind: "timeout"; stdout: string; stderr: string }
| { kind: "spawn_failed"; message: string }
| { kind: "aborted" };
export type SpawnSafeOptions = {
cwd: string | null;
/** When null, merges {@link nerveCommandEnv} over `process.env`. When set, merges over that default. */
env: SpawnEnv | null;
timeoutMs: number | null;
dryRun: boolean;
/** When non-null, child is terminated on abort; if `timeoutMs` is also null, no internal wall-clock timer is used. */
abortSignal: AbortSignal | null;
};
type SpawnSafeOptionsInput = SpawnSafeOptions | Omit<SpawnSafeOptions, "dryRun">;
const DEFAULT_TIMEOUT_MS = 300_000;
export function nerveCommandEnv(): SpawnEnv {
const home = homedir();
const pnpmHome = join(home, ".local/share/pnpm");
return {
...process.env,
PNPM_HOME: pnpmHome,
PATH: `${pnpmHome}:${process.env.PATH ?? ""}`,
};
}
function mergeEnv(user: SpawnEnv | null): SpawnEnv {
const base = nerveCommandEnv();
if (user === null) {
return base;
}
return { ...base, ...user };
}
function resolveWallClockMs(
timeoutMs: number | null,
abortSignal: AbortSignal | null,
): number | null {
if (timeoutMs === null) {
if (abortSignal !== null) {
return null;
}
return DEFAULT_TIMEOUT_MS;
}
return timeoutMs;
}
function resolveDryRun(options: SpawnSafeOptionsInput): boolean {
return "dryRun" in options ? options.dryRun : false;
}
function normalizeAbortSignal(options: SpawnSafeOptionsInput): AbortSignal | null {
return "abortSignal" in options ? options.abortSignal : null;
}
export function spawnSafe(
command: string,
args: ReadonlyArray<string>,
options: SpawnSafeOptionsInput,
): Promise<Result<SpawnResult, SpawnError>> {
const dryRun = resolveDryRun(options);
if (dryRun) {
return Promise.resolve(
ok({
stdout: "[dryRun] skipped",
stderr: "",
exitCode: 0,
signal: null,
}),
);
}
const abortSignal = normalizeAbortSignal(options);
if (abortSignal?.aborted) {
return Promise.resolve(err({ kind: "aborted" }));
}
return new Promise((resolve) => {
const cwd = options.cwd === null ? process.cwd() : options.cwd;
const env = mergeEnv(options.env);
const wallClockMs = resolveWallClockMs(options.timeoutMs, abortSignal);
const child = spawn(command, args, {
cwd,
env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = (outcome: Result<SpawnResult, SpawnError>) => {
if (settled) {
return;
}
settled = true;
if (timer !== undefined) {
clearTimeout(timer);
}
if (abortSignal !== null) {
abortSignal.removeEventListener("abort", onAbort);
}
resolve(outcome);
};
function onAbort() {
child.kill("SIGTERM");
finish(err({ kind: "aborted" }));
}
if (abortSignal !== null) {
abortSignal.addEventListener("abort", onAbort);
}
if (wallClockMs !== null) {
timer = setTimeout(() => {
child.kill("SIGTERM");
finish(err({ kind: "timeout", stdout, stderr }));
}, wallClockMs);
}
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
});
child.on("error", (cause: Error) => {
finish(err({ kind: "spawn_failed", message: cause.message }));
});
child.on("close", (code, signal) => {
const exitCode = code ?? 1;
const sig = signal === undefined || signal === null ? null : String(signal);
const result: SpawnResult = {
stdout: stdout.trimEnd(),
stderr: stderr.trimEnd(),
exitCode,
signal: sig,
};
if (exitCode !== 0) {
finish(
err({
kind: "non_zero_exit",
stdout: result.stdout,
stderr: result.stderr,
exitCode,
signal: sig,
}),
);
return;
}
finish(ok(result));
});
});
}
+1 -3
View File
@@ -1,7 +1,5 @@
import type { AgentConfig, AgentFn, WorkflowContext } from "@uncaged/nerve-core";
import { type Result, ok } from "@uncaged/nerve-core";
import { type SpawnEnv, type SpawnError, spawnSafe } from "./spawn-safe.js";
import { type Result, type SpawnEnv, type SpawnError, ok, spawnSafe } from "@uncaged/nerve-core";
/**
* Spawns a non-interactive `hermes chat` invocation with YOLO enabled, argv-only
-186
View File
@@ -1,186 +0,0 @@
import { spawn } from "node:child_process";
import { homedir } from "node:os";
import { join } from "node:path";
import { type Result, err, ok } from "@uncaged/nerve-core";
/** Compatible with `process.env` for `child_process.spawn`. */
export type SpawnEnv = Record<string, string | undefined>;
export type SpawnResult = {
stdout: string;
stderr: string;
exitCode: number;
/** OS signal name (e.g. `"SIGTERM"`) when terminated by signal; otherwise `null`. */
signal: string | null;
};
export type SpawnError =
| {
kind: "non_zero_exit";
stdout: string;
stderr: string;
exitCode: number;
signal: string | null;
}
| { kind: "timeout"; stdout: string; stderr: string }
| { kind: "spawn_failed"; message: string }
| { kind: "aborted" };
export type SpawnSafeOptions = {
cwd: string | null;
/** When null, merges {@link nerveCommandEnv} over `process.env`. When set, merges over that default. */
env: SpawnEnv | null;
timeoutMs: number | null;
dryRun: boolean;
/** When non-null, child is terminated on abort; if `timeoutMs` is also null, no internal wall-clock timer is used. */
abortSignal: AbortSignal | null;
};
type SpawnSafeOptionsInput = SpawnSafeOptions | Omit<SpawnSafeOptions, "dryRun">;
const DEFAULT_TIMEOUT_MS = 300_000;
export function nerveCommandEnv(): SpawnEnv {
const home = homedir();
const pnpmHome = join(home, ".local/share/pnpm");
return {
...process.env,
PNPM_HOME: pnpmHome,
PATH: `${pnpmHome}:${process.env.PATH ?? ""}`,
};
}
function mergeEnv(user: SpawnEnv | null): SpawnEnv {
const base = nerveCommandEnv();
if (user === null) {
return base;
}
return { ...base, ...user };
}
function resolveWallClockMs(
timeoutMs: number | null,
abortSignal: AbortSignal | null,
): number | null {
if (timeoutMs === null) {
if (abortSignal !== null) {
return null;
}
return DEFAULT_TIMEOUT_MS;
}
return timeoutMs;
}
function resolveDryRun(options: SpawnSafeOptionsInput): boolean {
return "dryRun" in options ? options.dryRun : false;
}
function normalizeAbortSignal(options: SpawnSafeOptionsInput): AbortSignal | null {
return "abortSignal" in options ? options.abortSignal : null;
}
export function spawnSafe(
command: string,
args: ReadonlyArray<string>,
options: SpawnSafeOptionsInput,
): Promise<Result<SpawnResult, SpawnError>> {
const dryRun = resolveDryRun(options);
if (dryRun) {
return Promise.resolve(
ok({
stdout: "[dryRun] skipped",
stderr: "",
exitCode: 0,
signal: null,
}),
);
}
const abortSignal = normalizeAbortSignal(options);
if (abortSignal?.aborted) {
return Promise.resolve(err({ kind: "aborted" }));
}
return new Promise((resolve) => {
const cwd = options.cwd === null ? process.cwd() : options.cwd;
const env = mergeEnv(options.env);
const wallClockMs = resolveWallClockMs(options.timeoutMs, abortSignal);
const child = spawn(command, args, {
cwd,
env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = (outcome: Result<SpawnResult, SpawnError>) => {
if (settled) {
return;
}
settled = true;
if (timer !== undefined) {
clearTimeout(timer);
}
if (abortSignal !== null) {
abortSignal.removeEventListener("abort", onAbort);
}
resolve(outcome);
};
function onAbort() {
child.kill("SIGTERM");
finish(err({ kind: "aborted" }));
}
if (abortSignal !== null) {
abortSignal.addEventListener("abort", onAbort);
}
if (wallClockMs !== null) {
timer = setTimeout(() => {
child.kill("SIGTERM");
finish(err({ kind: "timeout", stdout, stderr }));
}, wallClockMs);
}
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
});
child.on("error", (cause: Error) => {
finish(err({ kind: "spawn_failed", message: cause.message }));
});
child.on("close", (code, signal) => {
const exitCode = code ?? 1;
const sig = signal === undefined || signal === null ? null : String(signal);
const result: SpawnResult = {
stdout: stdout.trimEnd(),
stderr: stderr.trimEnd(),
exitCode,
signal: sig,
};
if (exitCode !== 0) {
finish(
err({
kind: "non_zero_exit",
stdout: result.stdout,
stderr: result.stderr,
exitCode,
signal: sig,
}),
);
return;
}
finish(ok(result));
});
});
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { spawnSafe } from "../shared/spawn-safe.js";
import { spawnSafe } from "../spawn-safe.js";
describe("spawnSafe", () => {
it("passes argv literally without shell interpretation (injection-safe)", async () => {
+8
View File
@@ -33,6 +33,14 @@ export type { Schema, ExtractFn } from "./extract-layer.js";
export { ExtractError } from "./extract-layer.js";
export type { Result } from "./result.js";
export { ok, err } from "./result.js";
export {
nerveCommandEnv,
spawnSafe,
type SpawnEnv,
type SpawnError,
type SpawnResult,
type SpawnSafeOptions,
} from "./spawn-safe.js";
export { parseNerveConfig } from "./parse-nerve-config.js";
export type { KnowledgeConfig } from "./knowledge-config.js";
export { parseKnowledgeYaml } from "./knowledge-config.js";
@@ -1,7 +1,7 @@
import { spawn } from "node:child_process";
import { homedir } from "node:os";
import { join } from "node:path";
import { type Result, err, ok } from "@uncaged/nerve-core";
import { type Result, err, ok } from "./result.js";
/** Compatible with `process.env` for `child_process.spawn`. */
export type SpawnEnv = Record<string, string | undefined>;
+1 -1
View File
@@ -25,7 +25,7 @@ export {
type SpawnError,
type SpawnResult,
type SpawnSafeOptions,
} from "./shared/spawn-safe.js";
} from "@uncaged/nerve-core";
export type { LlmError, LlmProvider } from "./shared/llm-extract.js";
export type {
CliPromptFn,
+1 -2
View File
@@ -1,11 +1,10 @@
import { type CursorAgentMode, cursorAgent } from "@uncaged/nerve-adapter-cursor";
import type { Role } from "@uncaged/nerve-core";
import type { Role, SpawnEnv } from "@uncaged/nerve-core";
import type { CursorRoleDefaults, CursorRoleRequired } from "./role-types.js";
import { isDryRun } from "./role-types.js";
import { formatLlmError } from "./shared/format-error.js";
import { llmExtract } from "./shared/llm-extract.js";
import type { SpawnEnv } from "./shared/spawn-safe.js";
const CURSOR_DEFAULTS: CursorRoleDefaults = {
mode: "default",
+1 -2
View File
@@ -1,8 +1,7 @@
import type { StartStep } from "@uncaged/nerve-core";
import type { SpawnEnv, StartStep } from "@uncaged/nerve-core";
import type { z } from "zod";
import type { LlmProvider } from "./shared/llm-extract.js";
import type { SpawnEnv } from "./shared/spawn-safe.js";
/** Returns the thread-level dry-run flag from the workflow start frame. */
export function isDryRun(start: StartStep): boolean {