mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
306 lines
11 KiB
TypeScript
306 lines
11 KiB
TypeScript
import {
|
|
spawn as nodeSpawn,
|
|
spawnSync as nodeSpawnSync,
|
|
type ChildProcess,
|
|
type ChildProcessWithoutNullStreams,
|
|
type SpawnOptions as NodeSpawnOptions
|
|
} from 'node:child_process'
|
|
import { buildWindowsCmdShimCommandLine, isCmdInterpretedProgram } from './windows-command-line'
|
|
|
|
/**
|
|
* The single place Orca starts a child process.
|
|
*
|
|
* Why one place: six decisions have to be made every time a child is spawned,
|
|
* POSIX forgives all six, and Windows punishes each of them differently —
|
|
* console visibility, argument quoting, `.cmd` interpretation, binary
|
|
* resolution, timeout policy, and how the tree is later terminated. Made
|
|
* per-call-site, they were right in some files and wrong in others, and the
|
|
* wrong ones reached users as stolen keyboard focus, mangled agent prompts and
|
|
* orphaned process trees.
|
|
*
|
|
* Callers outside this directory must not import `node:child_process`; a guard
|
|
* test enforces that against a shrinking allowlist.
|
|
*/
|
|
|
|
export type ProcessSpec = {
|
|
/**
|
|
* Program to run. On Windows this should already be an absolute path —
|
|
* spawning by bare name depends on the child's PATH, which under Group Policy
|
|
* or a stripped Electron environment can resolve to nothing.
|
|
*/
|
|
program: string
|
|
args?: readonly string[]
|
|
cwd?: string
|
|
env?: NodeJS.ProcessEnv
|
|
/** Kill the process (and, on Windows, its console) after this long. */
|
|
timeoutMs?: number
|
|
/** Written to stdin then closed. Omit to leave stdin empty and closed. */
|
|
input?: string
|
|
/** Cap on captured stdout/stderr; output past it is discarded. */
|
|
maxOutputBytes?: number
|
|
/** Kills the process when aborted; the result still reports the exit. */
|
|
signal?: AbortSignal
|
|
}
|
|
|
|
export type ProcessResult = {
|
|
code: number | null
|
|
signal: NodeJS.Signals | null
|
|
stdout: string
|
|
stderr: string
|
|
/** True when the process was killed by `timeoutMs` rather than exiting. */
|
|
timedOut: boolean
|
|
}
|
|
|
|
export const DEFAULT_PROCESS_TIMEOUT_MS = 30_000
|
|
export const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024
|
|
/**
|
|
* Grace between the timeout kill and giving up on the child's exit.
|
|
*
|
|
* Why give up at all: `close` only fires once the child is actually gone, and a
|
|
* child that ignores the kill never emits it -- so the promise would outlive
|
|
* its own deadline forever. Callers that cache an in-flight probe (the pwsh
|
|
* availability cache, the process-table reader) would then hand every later
|
|
* caller the same dead promise.
|
|
*/
|
|
const PROCESS_EXIT_GRACE_MS = 2_000
|
|
|
|
export type ResolvedSpawn = {
|
|
file: string
|
|
args: readonly string[]
|
|
options: NodeSpawnOptions
|
|
}
|
|
|
|
/**
|
|
* Translate a spec into the exact `child_process.spawn` call to make.
|
|
*
|
|
* Kept pure and exported so the Windows branch is testable from macOS/Linux:
|
|
* the decisions below are the whole point of this module, and they must not be
|
|
* observable only on the platform that breaks.
|
|
*/
|
|
export function resolveSpawn(spec: ProcessSpec, platform: NodeJS.Platform): ResolvedSpawn {
|
|
const args = spec.args ?? []
|
|
const base: NodeSpawnOptions = {
|
|
cwd: spec.cwd,
|
|
env: spec.env,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
// Why unconditional: Orca's main process is GUI-subsystem and owns no
|
|
// console, so every console-subsystem child it starts gets a fresh visible
|
|
// conhost that takes foreground — keystrokes typed into an Orca terminal at
|
|
// that moment land in the black box instead.
|
|
windowsHide: true,
|
|
// Why never `shell: true`: it concatenates arguments without escaping (Node
|
|
// itself warns DEP0190) and it silently makes windowsHide a no-op.
|
|
shell: false
|
|
}
|
|
|
|
if (platform !== 'win32' || !isCmdInterpretedProgram(spec.program)) {
|
|
return { file: spec.program, args, options: base }
|
|
}
|
|
|
|
// Node refuses to spawn `.cmd`/`.bat` without a shell (EINVAL, the
|
|
// CVE-2024-27980 mitigation), so cmd.exe has to be the program. Building the
|
|
// line ourselves — rather than handing Node `shell: true` — is what keeps the
|
|
// arguments intact and the console hidden.
|
|
const comSpec = spec.env?.ComSpec ?? process.env.ComSpec ?? 'cmd.exe'
|
|
return {
|
|
file: comSpec,
|
|
args: [buildWindowsCmdShimCommandLine(spec.program, args)],
|
|
options: { ...base, windowsVerbatimArguments: true }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start a child process. Use for long-lived or streaming children.
|
|
*
|
|
* The caller owns the returned streams, including their `error` events — an
|
|
* unhandled one is an uncaught exception that takes the main process down.
|
|
* `runProcess` handles that for you; here it cannot, because a blanket handler
|
|
* would also defeat callers that track and remove their own listeners.
|
|
*/
|
|
export function spawnProcess(spec: ProcessSpec): ChildProcessWithoutNullStreams {
|
|
const resolved = resolveSpawn(spec, process.platform)
|
|
return nodeSpawn(
|
|
resolved.file,
|
|
[...resolved.args],
|
|
resolved.options
|
|
) as ChildProcessWithoutNullStreams
|
|
}
|
|
|
|
/**
|
|
* Collects output up to a cap, so a chatty child cannot grow the heap.
|
|
*
|
|
* Accepts strings as well as buffers: a stream someone called `setEncoding` on
|
|
* emits strings, and concatenating those as buffers throws inside a `data`
|
|
* handler, where the rejection has nowhere to go and the caller just hangs.
|
|
*/
|
|
function createOutputSink(maxBytes: number): {
|
|
write: (chunk: Buffer | string) => void
|
|
text: () => string
|
|
} {
|
|
const chunks: Buffer[] = []
|
|
let bytes = 0
|
|
return {
|
|
write(raw) {
|
|
const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw)
|
|
const remaining = maxBytes - bytes
|
|
if (remaining <= 0) {
|
|
return
|
|
}
|
|
chunks.push(chunk.length > remaining ? chunk.subarray(0, remaining) : chunk)
|
|
bytes += chunk.length
|
|
},
|
|
text: () => Buffer.concat(chunks).toString('utf8')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run a child process to completion and capture its output.
|
|
*
|
|
* Never rejects on a non-zero exit — the exit code is data. Rejects only when
|
|
* the process could not be started at all.
|
|
*/
|
|
export function runProcess(spec: ProcessSpec): Promise<ProcessResult> {
|
|
const maxOutputBytes = spec.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES
|
|
|
|
return new Promise<ProcessResult>((resolve, reject) => {
|
|
let child: ChildProcess
|
|
try {
|
|
child = spawnProcess(spec)
|
|
} catch (error) {
|
|
reject(error)
|
|
return
|
|
}
|
|
|
|
const stdout = createOutputSink(maxOutputBytes)
|
|
const stderr = createOutputSink(maxOutputBytes)
|
|
let timedOut = false
|
|
let settled = false
|
|
|
|
const settle = (act: () => void): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
clearTimeout(timer)
|
|
clearTimeout(graceTimer)
|
|
spec.signal?.removeEventListener('abort', onAbort)
|
|
act()
|
|
}
|
|
|
|
child.stdout?.on('data', (chunk: Buffer | string) => stdout.write(chunk))
|
|
child.stderr?.on('data', (chunk: Buffer | string) => stderr.write(chunk))
|
|
// Why listeners that do nothing: an unhandled `error` on a stream is an
|
|
// uncaught exception, and that takes the whole main process down. A child
|
|
// that exits without reading makes the queued stdin write fail with EPIPE,
|
|
// and a broken pipe can surface on the read side too. The child's own
|
|
// `error` listener does not cover its streams. Losing output is not worth a
|
|
// crash, and the exit code still reaches the caller.
|
|
for (const stream of [child.stdin, child.stdout, child.stderr]) {
|
|
stream?.on('error', () => {})
|
|
}
|
|
|
|
let graceTimer: ReturnType<typeof setTimeout> | undefined
|
|
|
|
/**
|
|
* Stop the child, then settle whether or not it complies.
|
|
*
|
|
* Why settle regardless: `close` only fires once the child is really gone,
|
|
* so one that traps the signal would hold the caller forever -- and both
|
|
* the pwsh cache and the snapshot reader hand later callers their in-flight
|
|
* promise, so a single unkillable child would wedge every one of them.
|
|
*/
|
|
const stopAndSettle = (): void => {
|
|
terminate(child)
|
|
graceTimer ??= setTimeout(() => {
|
|
terminate(child, 'SIGKILL')
|
|
settle(() =>
|
|
resolve({
|
|
code: null,
|
|
signal: null,
|
|
stdout: stdout.text(),
|
|
stderr: stderr.text(),
|
|
timedOut
|
|
})
|
|
)
|
|
}, PROCESS_EXIT_GRACE_MS)
|
|
graceTimer.unref?.()
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
timedOut = true
|
|
stopAndSettle()
|
|
}, spec.timeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS)
|
|
timer.unref?.()
|
|
|
|
// Why the same escalation: an aborted caller has stopped waiting, so an
|
|
// unkillable child must not keep the promise alive on their behalf either.
|
|
const onAbort = (): void => stopAndSettle()
|
|
spec.signal?.addEventListener('abort', onAbort, { once: true })
|
|
// Why check after subscribing: a signal that was already aborted never
|
|
// fires the event, so the child would otherwise run to its full timeout on
|
|
// behalf of a caller who had already given up.
|
|
if (spec.signal?.aborted) {
|
|
onAbort()
|
|
}
|
|
|
|
child.once('error', (error) => settle(() => reject(error)))
|
|
child.once('close', (code, signal) =>
|
|
settle(() =>
|
|
resolve({ code, signal, stdout: stdout.text(), stderr: stderr.text(), timedOut })
|
|
)
|
|
)
|
|
|
|
// Why close rather than leave open: a child that reads stdin (a hook
|
|
// draining its payload, a CLI probing for a TTY) otherwise blocks until the
|
|
// timeout instead of seeing EOF immediately.
|
|
child.stdin?.end(spec.input)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Best-effort termination of a captured child.
|
|
*
|
|
* Deliberately root-only: descendant reaping is the job-object owner's
|
|
* responsibility, not every caller's.
|
|
*/
|
|
function terminate(child: ChildProcess, signal?: NodeJS.Signals): void {
|
|
try {
|
|
child.kill(signal)
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Synchronous variant, for the call sites that genuinely cannot await — CLI
|
|
* entry points and teardown paths that run while the event loop is stopping.
|
|
*
|
|
* Prefer `runProcess`. This exists so those callers still get the Windows
|
|
* invariants (hidden console, correct `.cmd` argv) instead of reaching for
|
|
* `execFileSync` and re-deciding them.
|
|
*/
|
|
export function runProcessSync(spec: ProcessSpec): ProcessResult {
|
|
const resolved = resolveSpawn(spec, process.platform)
|
|
const result = nodeSpawnSync(resolved.file, [...resolved.args], {
|
|
...resolved.options,
|
|
input: spec.input,
|
|
timeout: spec.timeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS,
|
|
maxBuffer: spec.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
|
|
encoding: 'buffer'
|
|
})
|
|
if (result.error && (result.error as NodeJS.ErrnoException).code !== 'ETIMEDOUT') {
|
|
throw result.error
|
|
}
|
|
return {
|
|
code: result.status,
|
|
signal: result.signal,
|
|
stdout: result.stdout?.toString('utf8') ?? '',
|
|
stderr: result.stderr?.toString('utf8') ?? '',
|
|
// Why ETIMEDOUT and not the signal: a timeout kills with SIGTERM, but so
|
|
// does anything else that terminates the child, and only a timeout also
|
|
// sets this error. Reading the signal alone reports a deliberately
|
|
// stopped process as having timed out, which callers retry.
|
|
timedOut: (result.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT'
|
|
}
|
|
}
|