/** * Engine-generic one-shot subprocess detour (#357 T2 / ADR 0069). * Spawn once; no retry, hang surface, or per-engine branch. * Material body is LLM data — this module only executes argv the model assembled. */ import { spawn } from "node:child_process"; /** Package-owned detour tool name (settlement whitelist + session principal). */ export const ENGINE_DETOUR_TOOL_NAME = "ak_engine_detour" as const; /** Env presence/name signal injected by public role runs (registration gate only). */ export const AK_ROLE_ENGINE_ENV = "AK_ROLE_ENGINE" as const; /** * Request-scoped engine flag on RoleHost (#818 P1). * Envelope projects RoleTurnRequest.engine here so concurrent in-process hosts * do not share process.env. Empty string = explicitly no engine (blocks ambient). * Pi never sets this flag — resolve falls through to child-process env. */ export const ENGINE_FLAG_NAME = "ak-engine" as const; /** Request-scoped engine model flag on RoleHost (#883). Empty = no model. */ export const ENGINE_MODEL_FLAG_NAME = "ak-engine-model" as const; /** Non-empty trimmed engine name, else undefined. */ export function normalizeEngineName(engine: string | undefined): string | undefined { if (engine === undefined) return undefined; const trimmed = engine.trim(); return trimmed === "" ? undefined : trimmed; } /** * Sole AK_ROLE_ENGINE write seam (#391 E2 / #818). * Child-process env only (pi adapter). Delete ambient first. Child-env objects * keep an own-key undefined mask so a later process.env re-merge cannot revive * ambient; process.env itself only deletes (Node stringifies undefined assignments). */ export function applyEngineChildEnv( childEnv: NodeJS.ProcessEnv, engine?: string, ): void { delete childEnv[AK_ROLE_ENGINE_ENV]; const normalized = normalizeEngineName(engine); if (normalized !== undefined) { childEnv[AK_ROLE_ENGINE_ENV] = normalized; } else if (childEnv !== process.env) { childEnv[AK_ROLE_ENGINE_ENV] = undefined; } } export const ENGINE_DETOUR_EMPTY_STDOUT_DIAGNOSTIC = "劳务引擎 stdout 为空" as const; export type EngineDetourResult = Readonly<{ code: number; stdout: string; stderr: string; }>; export type EngineDetourRunInput = Readonly<{ argv: readonly string[]; cwd: string; env?: NodeJS.ProcessEnv; signal?: AbortSignal; }>; function abortReasonError(signal: AbortSignal): Error { const reason = signal.reason; if (reason instanceof Error) return reason; if (typeof reason === "string" && reason.trim() !== "") { return new Error(reason); } const error = new Error("aborted"); error.name = "AbortError"; return error; } /** * Run one engine subprocess. First argv element is the executable (PATH lookup). * stdio: ignore stdin, pipe stdout+stderr. No shell, no retry, no hang timer. * AbortSignal cancels the child immediately via an explicit listener (reason preserved). */ export async function runEngineDetourOnce( input: EngineDetourRunInput, ): Promise { if (input.argv.length === 0) { throw new Error("劳务引擎 argv 不得为空"); } const command = input.argv[0]!; const args = input.argv.slice(1); return await new Promise((resolve, reject) => { let settled = false; const signal = input.signal; // Own abort→kill explicitly so rejection preserves signal.reason (caller cancel). // Do not pass `signal` to spawn (Node replaces reason). const child = spawn(command, args, { cwd: input.cwd, env: input.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; child.stdout.setEncoding("utf8").on("data", (chunk: string) => { stdout += chunk; }); child.stderr.setEncoding("utf8").on("data", (chunk: string) => { stderr += chunk; }); const fail = (error: unknown): void => { if (settled) return; settled = true; if (signal !== undefined) { signal.removeEventListener("abort", onAbort); } reject(error instanceof Error ? error : new Error(String(error))); }; const succeed = (result: EngineDetourResult): void => { if (settled) return; settled = true; if (signal !== undefined) { signal.removeEventListener("abort", onAbort); } resolve(result); }; const onAbort = (): void => { // Fail synchronously so caller-cancel soft-settle preserves signal.reason. fail(signal !== undefined ? abortReasonError(signal) : new Error("aborted")); try { child.kill("SIGTERM"); } catch { // already exited } }; if (signal !== undefined) { if (signal.aborted) { onAbort(); } else { signal.addEventListener("abort", onAbort, { once: true }); } } child.on("error", (error) => fail(error)); child.on("close", (code) => { succeed({ code: code ?? 1, stdout, stderr }); }); }); } /** Failure predicate: nonzero exit OR stdout trim-empty (including whitespace-only). */ export function isEngineDetourFailure(result: { code: number; stdout: string; }): boolean { return result.code !== 0 || result.stdout.trim() === ""; } /** * Diagnostic string for shared settlement / Terminal Error Artifact (#395). * Prefer engine stderr 原样; whitespace-only/empty stderr with stdout body must * carry the child's last result/error row verbatim (e.g. a 529 API Error row) — * never swallow the cause behind an exit code. Fully-empty output → stable fallback. */ export function engineDetourFailureDiagnostic(result: { stderr: string; code: number; stdout: string; }): string { if (result.stderr.trim().length > 0) return result.stderr; if (result.stdout.trim() === "") return ENGINE_DETOUR_EMPTY_STDOUT_DIAGNOSTIC; const rows = result.stdout.split("\n"); let lastRow = ""; for (let index = rows.length - 1; index >= 0; index -= 1) { if (rows[index]!.trim() !== "") { lastRow = rows[index]!; break; } } return `劳务引擎以 code ${result.code} 退出:${lastRow}`; } /** Non-empty trimmed engine name from process.env, else undefined. */ export function engineNameFromEnv(): string | undefined { return normalizeEngineName( typeof process.env[AK_ROLE_ENGINE_ENV] === "string" ? process.env[AK_ROLE_ENGINE_ENV] : undefined, ); } /** * One activation-signal resolver (#818 P1 / ADR 0069 one gate). * Request-scoped RoleHost flag wins when present (envelope always projects it, * including "" for no-engine so ambient env cannot arm). Flag absent → pi * child-process env via engineNameFromEnv. */ export function resolveEngineName( getFlag?: (name: string) => boolean | string | undefined, ): string | undefined { if (getFlag !== undefined) { const flag = getFlag(ENGINE_FLAG_NAME); if (typeof flag === "string") return normalizeEngineName(flag); } return engineNameFromEnv(); } /** * Request-scoped engine model resolver (#883). * Flag wins when present (including "" = no model). No process.env fallback — * model is seat-table only, never ambient. */ export function resolveEngineModel( getFlag?: (name: string) => boolean | string | undefined, ): string | undefined { if (getFlag === undefined) return undefined; const flag = getFlag(ENGINE_MODEL_FLAG_NAME); if (typeof flag !== "string") return undefined; return normalizeEngineName(flag); }