import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import type { EphemeralVmRecipeContext } from './ephemeral-vm-recipe-runner' import { admitProcessTreeKill } from './child-process/process-tree-kill-gate' const DEFAULT_MAX_CAPTURE_BYTES = 1024 * 1024 const CANCEL_FORCE_KILL_DELAY_MS = 5_000 export type ProcessRunResult = { stdout: string stderr: string exitCode: number | null signal: NodeJS.Signals | null aborted?: true } export function quoteShellToken(value: string): string { if (process.platform === 'win32') { // Inside cmd.exe double quotes, `^` is literal; an embedded `"` is escaped // by doubling it. This token is only displayed for manual cleanup, so it // must be valid when pasted into cmd.exe. return `"${value.replace(/"/g, '""')}"` } return `'${value.replace(/'/g, `'\\''`)}'` } export async function runRecipeCommand(args: { command: string repoPath: string context: EphemeralVmRecipeContext mode: 'create' | 'suspend' | 'resume' | 'destroy' resultSchemaVersion: 1 | 2 stdin?: string env?: NodeJS.ProcessEnv maxCaptureBytes?: number signal?: AbortSignal onStdout?: (chunk: string) => void onStderr?: (chunk: string) => void spawnCommand?: typeof spawn }): Promise { const maxBytes = args.maxCaptureBytes ?? DEFAULT_MAX_CAPTURE_BYTES const spawnCommand = args.spawnCommand ?? spawn return new Promise((resolve, reject) => { let child: ChildProcessWithoutNullStreams try { child = spawnCommand(args.command, { cwd: args.repoPath, detached: process.platform !== 'win32', env: buildRecipeEnv(args.env, args.mode, args.context, args.resultSchemaVersion), shell: true, windowsHide: true }) as ChildProcessWithoutNullStreams } catch (error) { reject(error) return } let stdout = '' let stderr = '' let settled = false let aborted = false let forceKillTimer: ReturnType | undefined const finish = (result: ProcessRunResult): void => { if (settled) { return } settled = true if (forceKillTimer) { clearTimeout(forceKillTimer) } args.signal?.removeEventListener('abort', abort) resolve(result) } const fail = (error: Error): void => { if (settled) { return } settled = true if (forceKillTimer) { clearTimeout(forceKillTimer) } args.signal?.removeEventListener('abort', abort) reject(error) } const abort = (): void => { if (settled) { return } aborted = true forceKillTimer = setTimeout(() => { if (settled) { return } killRecipeProcess(child, true) finish({ stdout, stderr, exitCode: null, signal: null, aborted: true }) child.stdin.destroy() child.stdout.destroy() child.stderr.destroy() child.unref() }, CANCEL_FORCE_KILL_DELAY_MS) forceKillTimer.unref() killRecipeProcess(child) } child.stdout.setEncoding('utf8') child.stderr.setEncoding('utf8') child.stdout.on('data', (chunk: string) => { stdout = appendBounded(stdout, chunk, maxBytes) args.onStdout?.(chunk) }) child.stderr.on('data', (chunk: string) => { stderr = appendBounded(stderr, chunk, maxBytes) args.onStderr?.(chunk) }) child.on('error', (error) => { fail(error) }) child.on('close', (exitCode, signal) => { finish({ stdout, stderr, exitCode, signal, ...(aborted ? { aborted: true } : {}) }) }) if (args.signal?.aborted) { abort() } else { args.signal?.addEventListener('abort', abort, { once: true }) } if (args.stdin) { child.stdin.end(args.stdin) } else { child.stdin.end() } }) } /** Exported for the refusal-fallback test; the abort path is otherwise unreachable. */ export function killRecipeProcess(child: ChildProcessWithoutNullStreams, force = false): void { const signal = force ? 'SIGKILL' : 'SIGTERM' if (process.platform === 'win32') { // Recipes run through `cmd.exe /c` (shell: true), so child.kill() would only // terminate the wrapper and orphan the actual recipe subprocess (e.g. a cloud // CLI mid-provision). taskkill /T walks and kills the whole tree. if (child.pid) { if ( !admitProcessTreeKill({ pid: child.pid, site: 'ephemeral-vm-recipe', scope: 'win-taskkill-tree' }) ) { // Refusal blocks the tree walk, not the termination: the root kill is // handle-addressed, so it cannot reach the recycled pid we refused. child.kill(signal) return } const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { windowsHide: true, stdio: 'ignore' }) killer.on('error', () => child.kill(signal)) return } child.kill(signal) return } if (child.pid) { try { // Recipes run through a shell; kill the process group so shell children do not linger. process.kill(-child.pid, signal) return } catch { // Fall back to killing the direct child if the process group is already gone. } } child.kill(signal) } function buildRecipeEnv( env: NodeJS.ProcessEnv | undefined, mode: 'create' | 'suspend' | 'resume' | 'destroy', context: EphemeralVmRecipeContext, resultSchemaVersion: 1 | 2 ): NodeJS.ProcessEnv { return { ...process.env, ...env, ORCA_VM_MODE: mode, ORCA_VM_INSTANCE_ID: context.instanceId ?? '', ORCA_RECIPE_ID: context.recipeId, ORCA_PROJECT_ID: context.projectId ?? '', ORCA_WORKSPACE_ID: context.workspaceId ?? '', ORCA_WORKSPACE_NAME: context.workspaceName ?? '', ORCA_REPO_PATH: context.repoPath, ORCA_REPO_URL: context.repoUrl ?? '', ORCA_REPO_BRANCH: context.branch ?? '', ORCA_REPO_REF: context.ref ?? '', ORCA_REPO_REF_HEAD: context.expectedRefHead ?? '', ORCA_RECIPE_RESULT_SCHEMA_VERSION: String(resultSchemaVersion), ORCA_VERSION: context.orcaVersion ?? '' } } function appendBounded(current: string, chunk: string, maxBytes: number): string { if (maxBytes <= 0) { return '' } const chunkBytes = Buffer.byteLength(chunk, 'utf8') if (chunkBytes >= maxBytes) { return utf8Tail(chunk, maxBytes) } return utf8Tail(current, maxBytes - chunkBytes) + chunk } function utf8Tail(value: string, maxBytes: number): string { const bytes = Buffer.from(value, 'utf8') if (bytes.byteLength <= maxBytes) { return value } let start = bytes.byteLength - maxBytes while (start < bytes.byteLength && (bytes[start]! & 0xc0) === 0x80) { start += 1 } return bytes.subarray(start).toString('utf8') }