refactor: deduplicate spawn-safe into @uncaged/nerve-core #249
@@ -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";
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
-1
@@ -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 () => {
|
||||
@@ -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>;
|
||||
@@ -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,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,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 {
|
||||
|
||||
Reference in New Issue
Block a user