chore: shorten process-host comments (#32262)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-23 07:33:54 -07:00
committed by GitHub
co-authored by OrcaWin
parent 4ed7fb08af
commit 8d313ea579
23 changed files with 101 additions and 611 deletions
@@ -14,8 +14,6 @@ import { dirname, join, relative, resolve } from 'node:path'
import { runProcess } from '@orca/process-host'
import lockfile from 'proper-lockfile'
// Source resolution bootstraps compilation without requiring an existing dist.
const STAGING_PREFIX = '.dist-staging-'
const LOCK_PATH = '.dist-build.lock'
const LOCK_STALE_MS = 30_000
@@ -99,7 +97,7 @@ async function syncCompiledOutput(stagingDir, distDir, assertLockHeld) {
await renameReplacing(source, target, assertLockHeld)
result.written += 1
}
// Outputs of renamed or deleted sources would otherwise ship and stay importable.
// Remove obsolete outputs so renamed or deleted modules cannot remain importable.
for (const file of existing) {
if (!stagedSet.has(file)) {
assertLockHeld()
@@ -152,7 +150,6 @@ export async function buildPackageDist(packageDir) {
compromised = error
}
})
// Reject before publishing after proper-lockfile observes a lost lock.
const assertLockHeld = () => {
if (compromised) {
throw new Error(
@@ -1,15 +1,4 @@
/**
* Adversarial argument corpus for the Windows command-line encoding.
*
* Every entry round-trips through a real `.cmd` shim on Windows 11 with the
* encoding in windows-command-line.ts. They are shared between the pure unit
* test (which asserts the encoded bytes) and the Windows integration test
* (which asserts what the child process actually receives), so a fix that only
* satisfies one parser cannot pass.
*
* The `%`, `&` and `"` cases are the ones that shipped as bugs: an argument
* containing `&` was truncated AND its remainder executed as a command.
*/
// Shared with real Windows .cmd round-trip tests so both parsers must preserve every argument.
export const WINDOWS_ARGUMENT_CORPUS: readonly { name: string; value: string }[] = [
{ name: 'plain', value: 'hello' },
{ name: 'empty', value: '' },
@@ -22,9 +11,7 @@ export const WINDOWS_ARGUMENT_CORPUS: readonly { name: string; value: string }[]
{ name: 'pipe', value: 'a|b' },
{ name: 'redirect', value: 'a<b>c' },
{ name: 'percent-pair', value: 'e%F%g' },
// The shape that broke: a backslash immediately before a percent. Quoting
// inserts a quote there, and a quote after a single backslash is an escaped
// quote to CommandLineToArgvW.
// Inserted quotes after backslashes must not become CommandLineToArgvW escapes.
{ name: 'percent-after-backslash', value: 'C:\\Users\\%F%\\x' },
{ name: 'percent-after-two-backslashes', value: 'C:\\\\%F%' },
{ name: 'percent-lone', value: '100% done' },
@@ -40,10 +27,7 @@ export const WINDOWS_ARGUMENT_CORPUS: readonly { name: string; value: string }[]
{ name: 'agent-prompt', value: 'Fix "src/a b.ts" & run tests 100% !now! ^ (v2)' }
]
/**
* Environment the Windows integration test must run with, so the `%VAR%` cases
* fail loudly instead of passing by accident against an undefined variable.
*/
// Defined variables expose accidental percent expansion instead of letting it pass silently.
export const WINDOWS_ARGUMENT_CORPUS_ENV: Readonly<Record<string, string>> = {
F: 'EXPANDED_F',
VAR: 'EXPANDED_VAR'
@@ -1,25 +1,13 @@
/**
* Shim bodies transcribed verbatim from a real Windows 11 install, plus
* builders that emit the same shapes around a caller-chosen target.
*
* The verbatim copies are what pins the shapes to reality: the resolver is only
* allowed to recognise files these generators actually write, and a copy here
* is how a reviewer checks that without a Windows box.
*
* Sources:
* %APPDATA%\npm\codex.cmd — npm cmd-shim, node script
* %APPDATA%\npm\agent-browser.cmd — npm cmd-shim, bundled .exe
* %APPDATA%\npm\pnx.cmd — npm cmd-shim, extensionless target
* <repo>\node_modules\.bin\vitest.cmd— pnpm @zkochan/cmd-shim, node script
* %LOCALAPPDATA%\pnpm\bin\pnpm.CMD — pnpm, bundled .exe
* %LOCALAPPDATA%\pnpm\bin\pn.CMD — pnpm alias, bare PATH command
/** Verbatim Windows 11 shims from:
* npm: %APPDATA%\npm\{codex,agent-browser,pnx}.cmd
* pnpm: <repo>\node_modules\.bin\vitest.cmd; %LOCALAPPDATA%\pnpm\bin\{pnpm,pn}.CMD
*/
function crlf(lines: readonly string[]): string {
return `${lines.join('\r\n')}\r\n`
}
/** npm's `cmd-shim` for a node script — the shape MDE flagged as `codex.cmd`. */
// npm node-script shim.
export const REAL_CODEX_CMD = crlf([
'@ECHO off',
'GOTO start',
@@ -40,14 +28,12 @@ export const REAL_CODEX_CMD = crlf([
'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\@openai\\codex\\bin\\codex.js" %*'
])
/** npm's `cmd-shim` for a package that ships its own executable. */
export const REAL_AGENT_BROWSER_CMD = crlf([
'@ECHO off',
'"%~dp0node_modules\\agent-browser\\bin\\agent-browser-win32-x64.exe" %*'
])
/** npm's `cmd-shim` for an extensionless target — cmd resolves it via PATHEXT,
* so it must NOT resolve. */
// Extensionless targets need cmd PATHEXT resolution and must not resolve directly.
export const REAL_PNX_CMD = crlf([
'@ECHO off',
'GOTO start',
@@ -66,7 +52,6 @@ const VITEST_NODE_PATH = [
'C:\\Users\\neil\\orca\\orca\\node_modules\\.pnpm\\node_modules'
].join(';')
/** pnpm's `.bin` shim: two interpreter branches plus the NODE_PATH prepend. */
export const REAL_VITEST_CMD = crlf([
'@SETLOCAL',
'@IF NOT DEFINED NODE_PATH (',
@@ -84,21 +69,18 @@ export const REAL_VITEST_CMD = crlf([
export const REAL_VITEST_NODE_PATH = VITEST_NODE_PATH
/** pnpm's global shim for a bundled executable. */
export const REAL_PNPM_CMD = crlf([
'@SETLOCAL',
'@"%~dp0\\..\\global\\v11\\27d0-19f7df4c136-1fab7163f1a52461\\node_modules\\@pnpm\\exe\\pnpm.exe" %*'
])
/** pnpm's `pn` alias: a bare PATH command, with nothing to resolve. */
// A bare PATH alias has no shim-relative target to resolve.
export const REAL_PN_CMD = crlf(['@echo off', 'pnpm %*'])
/** The npm `%_prog%` shape around an arbitrary script. */
export function npmProgNodeShim(scriptRelative: string): string {
return REAL_CODEX_CMD.replace('node_modules\\@openai\\codex\\bin\\codex.js', scriptRelative)
}
/** The pnpm two-branch shape, with the NODE_PATH block only when asked for. */
export function pnpmBranchedNodeShim(scriptRelative: string, nodePathPrefix?: string): string {
return crlf([
'@SETLOCAL',
@@ -120,7 +102,6 @@ export function pnpmBranchedNodeShim(scriptRelative: string, nodePathPrefix?: st
])
}
/** The npm one-line shape around an arbitrary target. */
export function npmDirectShim(targetRelative: string): string {
return crlf(['@ECHO off', `"%~dp0${targetRelative}" %*`])
}
@@ -1,13 +1,7 @@
import { Buffer } from 'node:buffer'
import { GrowingByteBuffer } from './growing-byte-buffer'
/**
* 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.
*/
// setEncoding streams emit strings; accepting them avoids exceptions in data handlers.
export function createOutputSink(
maxBytes: number,
outputCapture: 'head' | 'tail' = 'head'
@@ -44,8 +38,7 @@ export function createOutputSink(
chunks.push(chunk.length > remaining ? chunk.subarray(0, remaining) : chunk)
},
text: () => tail?.toString() ?? buffer().toString('utf8'),
// Why: callers that parse the output need to tell a short answer from a
// clipped one -- truncated JSON or JSONL parses as a smaller valid result.
// Truncated JSONL can still parse as a valid, incomplete result.
truncated: () => bytes > maxBytes
}
}
+3 -16
View File
@@ -1,6 +1,4 @@
// `fork` arm of Orca's child-process chokepoint. Kept beside `run-process.ts` for the same
// reason that file exists: callers outside this directory must not import `node:child_process`,
// and a guard test enforces that against a shrinking allowlist.
// All host process creation goes through this package; callers cannot import child_process directly.
import {
fork as nodeFork,
type ForkOptions,
@@ -8,17 +6,8 @@ import {
} from 'node:child_process'
import type { SpawnedProcess } from './process-spec'
/**
* What `spawnProcess` cannot express: a Node child with an IPC channel, started from a module
* path rather than a program, optionally under a different Node/Electron binary than this
* process's own.
*
* `fork` is the right primitive for that and there is no spawn-based substitute — the existing
* launch paths and their tests are written against `fork`'s contract (module path resolution,
* `execPath` override, inherited `execArgv`), and a spawn rewrite would change all three.
*/
// Preserve fork semantics for module resolution, execPath overrides, and inherited execArgv.
export type ForkSpec = {
/** Node module to run as the child's entry point. */
modulePath: string
args?: readonly string[]
cwd?: string
@@ -27,7 +16,6 @@ export type ForkSpec = {
detached?: boolean
/** Must keep an `'ipc'` slot; without one the child gets no channel and cannot `send`. */
stdio?: NodeSpawnOptions['stdio']
/** Run a different Node/Electron binary — e.g. a relocated executable image. */
execPath?: string
}
@@ -37,8 +25,7 @@ export function forkProcess(spec: ForkSpec): SpawnedProcess {
env: spec.env,
detached: spec.detached,
stdio: spec.stdio,
// Why conditional rather than `execPath: spec.execPath`: an explicit `undefined` is not the
// same as absent to Node, which reads the key to decide whether to override its own binary.
// Node treats an explicit undefined execPath differently from an absent key.
...(spec.execPath ? { execPath: spec.execPath } : {})
}
// Node forwards this undocumented fork option to spawn, preventing console flashes on Windows.
@@ -110,7 +110,6 @@ export class GrowingByteBuffer {
}
private reclaimHeadRoom(required: number): void {
// Slide the live bytes down while they fit in half the storage; only grow when they do not.
if (required * 2 <= this.storage.byteLength) {
this.storage.copy(this.storage, 0, this.start, this.start + this.length)
} else {
@@ -49,7 +49,6 @@ async function runProbe(options: {
signal?: string
census?: string
censusKillDenied?: boolean
/** Replaces the whole stub `lsof` body, for cases about what lsof answered. */
lsof?: string
sockPath?: string
}) {
@@ -166,17 +165,7 @@ describe.skipIf(process.platform === 'win32')('lsof supervisor lifecycle', () =>
})
})
/**
* When the probe runs as a uid that does not own the socket's holder, `lsof -t -a -U <path>`
* exits 1 with no stdout and no stderr. Measured on Debian 12: a live relay owned by root,
* probed as `nobody`, produced `exit=1 stdout=[] stderr=[]` — byte-identical to a genuinely
* stale socket, so no amount of inspecting lsof's answer can separate the two. That answer
* reaches `verdict: exited / evidence: no-holder`, which the host census reads as an idle
* endpoint a live relay still holds.
*
* The stub reproduces that exact shape rather than switching uid, which needs root and two
* accounts. What separates the cases is /proc/net/unix, which is world-readable.
*/
// Other-UID lsof misses look identical to stale sockets; stub that response without requiring root.
const linuxOnly = describe.skipIf(process.platform !== 'linux')
linuxOnly('lsof blindness to another uid', () => {
@@ -204,8 +193,6 @@ linuxOnly('lsof blindness to another uid', () => {
})
it('still reports lsof for an empty answer about a path nothing has bound', async () => {
// The control that keeps this from becoming a universal accumulation bug: on a healthy
// host a genuinely stale socket has no /proc/net/unix entry and must stay reapable.
const dir = mkdtempSync(join(tmpdir(), 'orca-lsof-stale-'))
try {
const result = await runProbe({ lsof: BLIND_LSOF, sockPath: join(dir, 'never-bound.sock') })
@@ -222,9 +209,7 @@ linuxOnly('lsof blindness to another uid', () => {
expect(result.stdout).toBe('lsof\n123\n')
})
// Both neighbours of the bound path, because a substring test would pass one and fail the
// other: `<sock>` is a prefix of the bound path, and the bound path is a prefix of
// `<sock>.other`. Only exact equality answers correctly for both.
// Test both prefix directions to reject substring-based socket matches.
it.each([
['a prefix of the bound path', (sockPath: string) => sockPath.slice(0, -1)],
['a path the bound one prefixes', (sockPath: string) => `${sockPath}.other`]
@@ -20,19 +20,14 @@ function groupExists(pid) {
try { process.kill(-pid, 0); return true; }
catch (error) { return error.code !== 'ESRCH'; }
}
// An empty lsof answer means "nobody holds it" only if lsof could see. Running as a uid that
// does not own the holder, lsof exits 1 with no stdout and no stderr -- byte-identical to a
// genuinely stale socket. /proc/net/unix is world-readable and lists every bound unix socket
// regardless of owner, so an entry for this path alongside no pid proves lsof was blind.
// Absent off Linux, where this returns false and the marker stays whatever it already was.
// lsof cannot distinguish other-UID holders from stale sockets; corroborate with /proc/net/unix.
function pathStillBound(target) {
var text;
try { text = require('fs').readFileSync('/proc/net/unix', 'utf8'); }
catch (error) { return false; }
var lines = text.split('\n');
for (var i = 0; i < lines.length; i++) {
// Num: RefCount Protocol Flags Type St Inode Path -- the path is the line remainder, so
// one containing spaces survives intact.
// The path is the line remainder and may contain spaces.
var match = lines[i].match(/^\S+:(?:\s+\S+){5}\s+\d+ (.*)$/);
if (match && match[1] === target) return true;
}
@@ -54,7 +49,6 @@ function finish(unconfirmed) {
unavailable = true;
return false;
});
// Only an otherwise-clean empty answer needs corroborating; a reported pid stands on its own.
if (!unconfirmed && !unavailable && !stderrSeen && !pids.length && pathStillBound(process.argv[1])) {
unavailable = true;
}
+2 -12
View File
@@ -9,18 +9,12 @@ export type ChildProcessHandle = ChildProcess
export type SpawnedProcess = ChildProcess
/** Centralizes host execution settings so argument encoding and tree ownership stay consistent. */
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.
*/
/** Prefer an absolute Windows path; stripped Electron PATHs may not resolve bare names. */
program: string
args?: readonly string[]
cwd?: string
env?: NodeJS.ProcessEnv
/** Kill the process (and, on Windows, its console) after this long. */
timeoutMs?: number | null
/** Written to stdin then closed. Omit to leave stdin empty and closed. */
input?: string
@@ -28,15 +22,13 @@ export type ProcessSpec = {
maxOutputBytes?: number
/** Capture stdout as bytes without decoding; stdout stays empty in this mode. */
captureStdoutAsBytes?: boolean
/** Stop a parser command as soon as captured output exceeds its cap. */
killOnOutputLimit?: boolean
/** Kills the process when aborted; the result still reports the exit. */
/** Aborting still resolves with the process exit result. */
signal?: AbortSignal
/** Keep the child in its own POSIX process group for tree termination. */
detached?: boolean
/** Preserve a caller-owned Windows command line such as a cmd.exe invocation. */
windowsVerbatimArguments?: boolean
/** Streaming callers may suppress child output for auxiliary processes. */
stdio?: NodeSpawnOptions['stdio']
/** Bun and Node require JSON for an IPC channel shared between the two runtimes. */
serialization?: NodeSpawnOptions['serialization']
@@ -68,9 +60,7 @@ export type ProcessResult = {
stdout: string
stdoutBytes?: Buffer
stderr: string
/** True when the process was killed by `timeoutMs` rather than exiting. */
timedOut: boolean
/** True when stdout or stderr exceeded `maxOutputBytes` and was clipped. */
outputTruncated?: boolean
}
@@ -1,19 +1,6 @@
/**
* Seam that lets the main process decide, and record, the tree-kills issued
* from code it does not own.
*
* Why a seam and not a direct call: `signalProcessTree` is the choke point every
* `runProcess` termination funnels through, and the codex app-server and
* ephemeral-VM kills are shared with the CLI — all of them live outside
* `src/main` and cannot import the own-Chromium guard or the crash breadcrumb
* store. Main registers the guard at startup; everywhere else this admits every
* kill and records nothing.
*/
// Main installs its Chromium kill guard here; daemon, relay, and CLI cannot import main code.
/** Blast radius, not mechanism: `win-taskkill-tree` is addressed by pid and walks
* whatever tree that pid has *now*, so it can land on a recycled pid that is
* since one of Orca's own Chromium processes. A process group can only contain
* processes Orca itself put there. */
/** Windows tree walks can target reused PIDs; POSIX groups contain only Orca-assigned processes. */
export type ProcessTreeKillScope = 'win-taskkill-tree' | 'posix-process-group'
export type ProcessTreeKill = {
@@ -22,9 +9,7 @@ export type ProcessTreeKill = {
scope: ProcessTreeKillScope
}
/** False means the caller must not walk that pid's tree — main is accounting for
* it. Killing the root through its own child handle stays correct and required:
* a handle cannot land on the recycled pid the refusal is about. */
/** Refusal forbids the PID-based tree walk; callers must still kill the root through its handle. */
type ProcessTreeKillGate = (kill: ProcessTreeKill) => boolean
let gate: ProcessTreeKillGate | null = null
@@ -138,11 +138,9 @@ describe('forceTerminateProcessTree', () => {
await vi.advanceTimersByTimeAsync(5_000)
await expect(pending).resolves.toBe(false)
// Killing either now would orphan the descendants taskkill has not reached.
expect(taskkill.kill).not.toHaveBeenCalled()
expect(child.kill).not.toHaveBeenCalled()
// A retry joins the same taskkill and sees its late success.
const retry = forceTerminateProcessTree(child)
taskkill.emit('close', 0)
await expect(retry).resolves.toBe(true)
@@ -242,14 +240,10 @@ describe('process-tree-kill breadcrumb seam', () => {
it('never taskkills a pid the child already gave back to Windows', async () => {
await withWindows(async () => {
// A reaped pid is Windows' to reissue, and this host may be the daemon or
// relay, where the main-process own-Chromium guard cannot run.
const child = mockProcess(1234)
child.exitCode = 0
// `false`, not `true`: a taskkill against a reaped pid already resolved to
// `false`, and reporting verified termination here would release the git
// admission grant on root exit instead of on `close`.
// Root exit alone cannot release the barrier while descendants still hold the pipes.
await expect(signalProcessTree(child, 'SIGKILL')).resolves.toBe(false)
expect(spawnMock).not.toHaveBeenCalled()
@@ -10,49 +10,19 @@ const PROBE_INTERVAL_MS = 25
const SUBPROCESS_TIMEOUT_MS = 2_000
const MAX_PS_OUTPUT_BYTES = 8 * 1024 * 1024
/**
* Signal the child's whole tree.
*
* POSIX precondition: the child must have been spawned `detached`. The signal
* goes to the process group `-child.pid`, so a child that is not its own group
* leader would hand it to whatever group it inherited instead.
*
* Runs in every host — Electron main, the daemon, the relay, the CLI — so the
* own-Chromium guard arrives through `process-tree-kill-gate`, which main
* installs and every other host leaves admitting. The exit check below is what
* keeps the Windows branch off a pid that is no longer ours on those hosts.
*
* Both arms ask before walking the tree, so a refused pid never gets a
* pid-addressed kill; that also means the recorded crumb says "about to kill",
* not "killed". The root is still killed through its handle, which cannot reach
* the recycled pid the refusal was about, so a refusal is never a leak. Main's
* gate only ever refuses the `win-taskkill-tree` scope — a POSIX group holds
* only what Orca put in it — so the POSIX refusal arm is the seam's contract,
* not something any installed gate exercises today.
*/
/** POSIX children must be detached: signaling -pid targets the process group. */
export function signalProcessTree(child: ChildProcess, signal?: NodeJS.Signals): Promise<boolean> {
if (!child.pid) {
killRoot(child, signal)
return Promise.resolve(true)
}
if (process.platform === 'win32') {
// Why first: a root that the in-flight taskkill already killed must join
// that kill's verdict, not read as a reaped pid.
// A tracked taskkill may have killed the root already; join its verdict before checking exit.
const tracked = trackedWindowsTreeKill(child)
if (tracked) {
return waitForWindowsTreeKill(tracked)
}
// Why the exit check: once the child is reaped its pid is Windows' to
// reissue, and `taskkill /t /f` walks whatever tree owns it *now* — a
// recycled pid can be one of Orca's own Chromium processes (#10680). The
// POSIX branch below cannot hit this: killing a reaped group is ESRCH.
//
// Why `false`: this is exactly what a taskkill against a reaped pid already
// resolved to (non-zero exit -> killRoot + `false`), so skipping the unsafe
// spawn must not also flip the termination barrier to "verified". Reporting
// `true` here would release the git admission grant on root exit instead of
// on `close`, admitting the next git command while a descendant that
// inherited the pipes still holds the repo.
// A reaped Windows PID can be reused; root exit alone does not prove descendants are gone.
if (childProcessHasExited(child)) {
killRoot(child, signal)
return Promise.resolve(false)
@@ -66,8 +36,7 @@ export function signalProcessTree(child: ChildProcess, signal?: NodeJS.Signals):
scope: 'posix-process-group'
})
) {
// Same shape as the reaped-pid skip above: refuse the group, still kill the
// root by handle, and report unverified.
// Refuse the group kill but still terminate the root through its handle.
killRoot(child, signal)
return Promise.resolve(false)
}
@@ -100,7 +69,6 @@ function taskkillTree(
rootPid: number,
signal?: NodeJS.Signals
): Promise<boolean> {
// Asked before the spawn, not after: a refusal has to prevent the taskkill.
if (
!admitProcessTreeKill({ pid: rootPid, site: 'run-process-tree', scope: 'win-taskkill-tree' })
) {
@@ -60,7 +60,7 @@ describe('runProcess barrier with a slow Windows taskkill', () => {
settled = true
})
// Past the caller's 5 s taskkill wait and the 2 s exit grace.
// Exceed both the taskkill wait and root-exit grace.
await vi.advanceTimersByTimeAsync(8_000)
expect(settled).toBe(false)
expect(root.kill).not.toHaveBeenCalled()
@@ -85,10 +85,10 @@ describe('runProcess barrier with a slow Windows taskkill', () => {
})
await vi.advanceTimersByTimeAsync(1_000)
// taskkill reached the root; its pipes close while descendants may still be dying.
// The root can exit before taskkill finishes its descendants.
root.emit('exit', 1, null)
root.emit('close', 1, null)
// The git admission slot is a concurrency budget, not a repo lock: it never outlives the root.
// The admission budget follows root lifetime, independently of tree verification.
expect(onChildTerminated).toHaveBeenCalledOnce()
let settled = false
void pending.then(() => {
@@ -36,8 +36,6 @@ describe('resolveSpawn', () => {
})
it('spawns a non-cmd program directly, letting Node do the argv quoting', () => {
// Node's own Windows quoting is already CommandLineToArgvW-correct for real
// executables; re-implementing it here would add risk for no gain.
const resolved = resolveSpawn({ program: 'C:\\bin\\agent.exe', args: ['a b'] }, 'win32')
expect(resolved.file).toBe('C:\\bin\\agent.exe')
expect(resolved.args).toEqual(['a b'])
@@ -54,8 +52,6 @@ describe('resolveSpawn', () => {
})
it('treats a .cmd path as an ordinary program off Windows', () => {
// A POSIX host running a file that happens to end in .cmd must not gain a
// cmd.exe hop; the extension carries no meaning there.
const resolved = resolveSpawn(SPEC, 'linux')
expect(resolved.file).toBe(SPEC.program)
expect(resolved.args).toEqual(SPEC.args)
@@ -65,8 +61,7 @@ describe('resolveSpawn', () => {
for (const { name, value } of WINDOWS_ARGUMENT_CORPUS) {
const line = resolveSpawn({ program: 'C:\\a.cmd', args: [value] }, 'win32').args[0]!
const body = line.slice('/d /v:off /s /c "'.length, -1)
// Every `&`/`|`/`<`/`>` must sit inside a quoted run. Walking the parity
// is the same thing cmd does, so this is the property that matters.
// cmd uses quote parity to decide whether metacharacters are operators.
let quoted = false
for (const char of body) {
if (char === '"') {
@@ -116,17 +111,12 @@ describe('runProcessSync', () => {
})
it('does not report a deliberately terminated child as timedOut', () => {
// Both cases exit on SIGTERM, so reading the signal alone conflates them —
// and a caller that retries on timeout would then retry a process someone
// stopped on purpose.
const result = runProcessSync({
program: process.execPath,
args: ['-e', 'process.kill(process.pid, "SIGTERM")'],
timeoutMs: 30_000
})
// Why not assert the signal: Windows has no signals, so the same deliberate
// kill reports an exit code there and a signal on POSIX. What must hold on
// both is that neither shape reads as a timeout.
// Windows reports an exit code for deliberate termination; POSIX reports a signal.
expect(result.timedOut).toBe(false)
expect(result.code === 0 && result.signal === null).toBe(false)
})
@@ -178,9 +168,6 @@ describe('stderr streaming', () => {
describe('unkillable children', () => {
it('settles after the grace period rather than outliving its own deadline', async () => {
// `close` only fires once the child is gone, so a child that ignores the
// kill would otherwise hold the promise forever — and callers that cache an
// in-flight probe would hand every later caller the same dead promise.
const result = await runProcess({
program: process.execPath,
args: [
@@ -196,8 +183,6 @@ describe('unkillable children', () => {
describe('abort', () => {
it('settles after the grace period when an aborted child ignores the signal', async () => {
// An aborted caller has stopped waiting; an unkillable child must not keep
// the promise alive on their behalf either.
const controller = new AbortController()
let stderr = ''
const pending = runProcess({
@@ -263,8 +248,7 @@ describe('abort', () => {
describe('stdin delivery failures', () => {
it('survives a child that exits without reading its input', async () => {
// The queued write fails with EPIPE, and an unhandled error on a stream is
// an uncaught exception — it would take the whole main process down.
// A queued stdin write may fail with EPIPE after the child exits.
const result = await runProcess({
program: process.execPath,
args: ['-e', 'process.exit(7)'],
@@ -278,8 +262,6 @@ describe('stdin delivery failures', () => {
describe('a signal that is already aborted', () => {
it('stops the child instead of running it to the timeout', async () => {
// addEventListener never fires for an abort that already happened, so the
// caller would wait out the full deadline having already given up.
const controller = new AbortController()
controller.abort()
const onChildTerminated = vi.fn()
+17 -78
View File
@@ -20,39 +20,17 @@ import type {
ProcessResult
} from './process-spec'
import { DEFAULT_PROCESS_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES } from './process-spec'
/**
* 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.
*/
// Bound waits for children that ignore termination and never emit close.
const PROCESS_EXIT_GRACE_MS = 2_000
/**
* Last resort for a barrier caller once tree termination could not be verified.
*
* Why bounded: waiting for the root's exit is what stops an unverified caller
* mutating shared state under a live child, but a tree that neither dies nor
* reports would otherwise leave the promise pending for the app's lifetime.
*/
// An unverifiable, silent tree must not leave barrier callers pending forever.
const BARRIER_UNVERIFIED_EXIT_GRACE_MS = 10_000
/**
* 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.
*/
/** Callers own stream error handling; unhandled stream errors can crash the host process. */
export function spawnProcess(spec: PipedProcessSpec): PipedChildProcess
export function spawnProcess(spec: ProcessSpec): ChildProcess
export function spawnProcess(spec: ProcessSpec): ChildProcess {
const resolved = resolveSpawn(spec, process.platform)
// Diagnostics only: uv_spawn runs synchronously on the calling thread, so this
// brackets the main-thread block for every child started through the wrapper.
// uv_spawn blocks the calling thread; measure that block for diagnostics.
const spawnStartedAt = hasSpawnObserver() ? performance.now() : null
const child = nodeSpawn(resolved.file, [...resolved.args], resolved.options)
if (spawnStartedAt !== null) {
@@ -61,13 +39,7 @@ export function spawnProcess(spec: ProcessSpec): ChildProcess {
return child
}
/**
* 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.
* Tail capture keeps final diagnostics without changing termination policy.
*/
/** Nonzero exits resolve; spawn errors reject. Tail capture preserves final diagnostics. */
export function runProcess(
spec: ProcessSpec,
outputCapture: 'head' | 'tail' = 'head'
@@ -130,12 +102,7 @@ export function runProcess(
spec.terminationBarrier.observeStderr?.(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.
// Stream errors (including stdin EPIPE) are otherwise uncaught; the exit still reaches callers.
for (const stream of [child.stdin, child.stdout, child.stderr]) {
stream?.on('error', () => {})
}
@@ -181,8 +148,7 @@ export function runProcess(
terminationReporter.report()
resolveBarrierIfSafe()
}
// A root killed before taskkill reads the tree orphans the descendants it
// has not reached, so an in-flight taskkill owns that fallback.
// Let taskkill finish reading the tree before killing the root, or descendants may be orphaned.
const killRootUnlessTreeKillInFlight = (): void => {
if (!inFlightWindowsTreeKill(child)) {
terminate(child, 'SIGKILL')
@@ -198,20 +164,12 @@ export function runProcess(
if (!barrierAttemptComplete) {
return
}
// Why a second deadline: the tree survived every attempt and the root has
// gone silent, so nothing else will ever settle this promise.
// Nothing else can settle a silent tree after all termination attempts fail.
barrierDeadlineTimer ??= setTimeout(settleBarrierOutcome, BARRIER_UNVERIFIED_EXIT_GRACE_MS)
barrierDeadlineTimer.unref?.()
}
/**
* Stop the child, then settle.
*
* Without a barrier, settle whether or not the child complies. With one, wait
* for verified tree termination or a root exit first — a descendant can keep
* `close` pending after the root exits, but failed verification must not let
* callers mutate shared state — then settle on the deadline regardless.
*/
// Barrier callers wait for termination evidence, bounded by a final deadline.
const stopAndSettle = (): void => {
if (spec.terminationBarrier) {
barrierStopping = true
@@ -239,8 +197,7 @@ export function runProcess(
barrierTerminationVerified = initialTerminated || forceTerminated
terminationReporter.reportIf(barrierTerminationVerified)
if (!barrierTerminationVerified) {
// The barrier never confirmed the tree died, so the root
// would otherwise outlive the abort or timeout.
// Unverified termination must not leave the root running beyond abort/timeout.
killRootUnlessTreeKillInFlight()
}
resolveBarrierIfSafe()
@@ -287,13 +244,9 @@ export function runProcess(
}, 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.
// Already-aborted signals emit no event; recheck after subscribing to close the race.
if (spec.signal?.aborted) {
onAbort()
}
@@ -329,14 +282,12 @@ export function runProcess(
resolveFromClose(code, signal)
})
// 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.
// Send EOF so stdin readers do not wait until timeout.
child.stdin?.end(spec.input)
})
}
/** Best-effort root termination, or whole-tree termination for barrier callers. */
// Best-effort root termination; tree termination is handled separately.
function terminate(child: ChildProcess, signal?: NodeJS.Signals): void {
try {
child.kill(signal)
@@ -345,18 +296,10 @@ function terminate(child: ChildProcess, signal?: NodeJS.Signals): void {
}
}
/**
* 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.
*/
/** Prefer runProcess; reserve blocking execution for synchronous entry points and teardown. */
export function runProcessSync(spec: ProcessSpec): ProcessResult {
const resolved = resolveSpawn(spec, process.platform)
// Diagnostics only: spawnSync holds the calling thread until the child exits,
// so the whole call is the block, reported even when it throws.
// spawnSync blocks until exit; report that duration even if it throws.
const spawnStartedAt = hasSpawnObserver() ? performance.now() : null
try {
return runResolvedProcessSync(spec, resolved)
@@ -388,13 +331,9 @@ function runResolvedProcessSync(spec: ProcessSpec, resolved: ResolvedSpawn): Pro
stdout: spec.captureStdoutAsBytes ? '' : (result.stdout?.toString('utf8') ?? ''),
...(spec.captureStdoutAsBytes ? { stdoutBytes: result.stdout ?? Buffer.alloc(0) } : {}),
stderr: result.stderr?.toString('utf8') ?? '',
// Why always false: spawnSync reports an overrun as an ENOBUFS error, and
// the guard above rethrows it, so no truncated result reaches this point.
// ENOBUFS is rethrown above, so truncated output never reaches this result.
outputTruncated: false,
// 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.
// SIGTERM can be deliberate; only ETIMEDOUT proves a timeout.
timedOut
}
}
@@ -1,12 +1,4 @@
/**
* Diagnostic seam for counting every child process started through this
* package. `src/main/diagnostics/main-thread-churn-probe` registers itself here
* under ORCA_MAIN_THREAD_DIAGNOSTICS=1; with nothing registered every spawn
* pays one undefined check, so production behaviour is unchanged.
*
* Why a seam rather than a direct import: run-process.ts also runs in the
* terminal daemon, the relay and the CLI, none of which may depend on main.
*/
// Main installs diagnostics here; daemon, relay, and CLI cannot depend on main code.
export type SpawnObserver = (command: string, args: readonly string[], blockMs: number) => void
let observer: SpawnObserver | null = null
@@ -9,18 +9,7 @@ export type ResolvedSpawn = {
options: NodeSpawnOptions
}
/**
* Translate a spec into the exact `child_process.spawn` call to make.
*
* Exported so the Windows branch is testable from macOS/Linux: the decisions
* below are the whole point of the spawn chokepoint, and they must not be
* observable only on the platform that breaks.
*
* Pure except on the win32 `.cmd` branch, where shim resolution does a `stat`
* and (on a cache miss) one bounded read of the shim itself. Both are inside
* try/catch and any failure falls back to the cmd.exe path, so the function
* still cannot throw or reach anything but the program path it was handed.
*/
/** Host-independent for Windows tests; recognized shim I/O failures fall back to cmd.exe. */
export function resolveSpawn(spec: ProcessSpec, platform: NodeJS.Platform): ResolvedSpawn {
const args = spec.args ?? []
const base: NodeSpawnOptions = {
@@ -28,15 +17,11 @@ export function resolveSpawn(spec: ProcessSpec, platform: NodeJS.Platform): Reso
env: spec.env,
stdio: spec.stdio ?? ['pipe', 'pipe', 'pipe'],
serialization: spec.serialization,
// 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.
// GUI-subsystem parents otherwise spawn visible consoles that steal foreground input.
windowsHide: true,
detached: spec.detached,
windowsVerbatimArguments: spec.windowsVerbatimArguments,
// Why never `shell: true`: it concatenates arguments without escaping (Node
// itself warns DEP0190) and it silently makes windowsHide a no-op.
// shell:true loses argument escaping and disables windowsHide.
shell: false,
...(spec.terminationBarrier && platform !== 'win32' ? { detached: true } : {})
}
@@ -45,12 +30,7 @@ export function resolveSpawn(spec: ProcessSpec, platform: NodeJS.Platform): Reso
return { file: spec.program, args, options: base }
}
// An npm/pnpm shim is a generated file that only locates node and runs a
// script, so reading it lets us spawn that program directly. That drops
// cmd.exe from the tree — which is what Defender scores as obfuscation once
// the caret-escaped payload is agent prompt text — and lifts cmd's ban on
// arguments containing a line break. Unrecognised shims resolve to null and
// keep the cmd.exe path below.
// Direct shim resolution avoids cmd escaping, EDR obfuscation flags, and multiline-argument limits.
const shim = resolveWindowsCmdShim(spec.program, spec.env ?? process.env)
if (shim) {
return {
@@ -59,19 +39,13 @@ export function resolveSpawn(spec: ProcessSpec, platform: NodeJS.Platform): Reso
options: {
...base,
...(shim.env ? { env: shim.env } : {}),
// Why cleared rather than inherited: the flag exists for callers that
// hand us a whole pre-built command line, and there is no such line
// here — Node would join `[script, ...args]` unquoted and shred any
// argument containing a space.
// Node must quote argv normally after replacing the caller-owned cmd line.
windowsVerbatimArguments: undefined
}
}
}
// 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.
// Node rejects direct .cmd/.bat spawns; build the cmd line ourselves to preserve quoting and hiding.
const comSpec = spec.env?.ComSpec ?? process.env.ComSpec ?? 'cmd.exe'
return {
file: comSpec,
@@ -1,107 +1,57 @@
/**
* Resolve an npm/pnpm-generated `.cmd` shim to the program it would have run.
*
* Why: a `.cmd` target forces the spawn through `cmd.exe /c` with every
* argument caret-escaped (see windows-command-line.ts for that encoding). A
* long `cmd.exe /c` line whose caret-escaped payload is natural-language agent
* prompt text is what Microsoft Defender for Endpoint's command-line model
* scores as obfuscation, and `codex.cmd` sits in the spawn cluster of a real
* MDE incident against Orca. These shims are generated files whose entire body
* is "find node, run this script", so reading one and spawning
* `node.exe <script> <args…>` removes cmd.exe — and with it the escaping —
* from the process tree.
*
* It also removes a real limitation: cmd's parser ends the command at a raw
* CR/LF whatever the quote state, so a multi-line agent prompt through a `.cmd`
* shim has to be rejected outright. The resolved path has no such problem.
*
* Everything here is deliberately all-or-nothing. A file that does not match a
* known shape exactly, or whose resolved target cannot be confirmed on disk,
* returns null and the caller keeps today's `cmd.exe /c` behaviour. A
* mis-resolution silently runs the wrong program or drops arguments, which is
* far worse than an EDR alert.
*/
// Resolve only exact known shims with verified targets; otherwise preserve cmd.exe fallback.
import { readFileSync, statSync, type Stats } from 'node:fs'
import { win32 } from 'node:path'
/** Escape hatch if resolution ever picks the wrong target in the field. */
const DISABLE_FLAG = 'ORCA_DISABLE_CMD_SHIM_RESOLUTION'
/** Real shims are under 2KB; anything larger is not one of these generators. */
// Bound reads of unrecognized or malformed shim files.
const MAX_SHIM_BYTES = 64 * 1024
/** Both spellings of the shim's own directory. Each already ends in `\`, so the
* separator the shim writes after it is optional and inert. */
// Both dp0 spellings end in a separator; tolerate a redundant trailing one.
const DP0 = String.raw`(?:%~dp0|%dp0%)\\?`
const DP0_NODE_EXE = `"${DP0}node\\.exe"`
const dp0Path = (group: string): string => `"${DP0}(?<${group}>[^"\\r\\n]+)"`
const ECHO_OFF = String.raw`@echo off\n`
/** npm's `cmd-shim` captures its own directory through a subroutine. */
const FIND_DP0 = String.raw`GOTO start\n:find_dp0\nSET dp0=%~dp0\nEXIT /b\n:start\nSETLOCAL\nCALL :find_dp0\n`
/** pnpm prepends its virtual-store directories so the script can resolve deps. */
const NODE_PATH_BLOCK = String.raw`(?:@IF NOT DEFINED NODE_PATH \(\n@SET "NODE_PATH=(?<nodePath>[^"\r\n]*)"\n\) ELSE \(\n@SET "NODE_PATH=(?<nodePathElse>[^"\r\n]*)"\n\)\n)?`
const PATHEXT_STRIP = String.raw`SET PATHEXT=%PATHEXT:;\.JS;=;%`
/**
* Current `cmd-shim`: picks the interpreter into `%_prog%`, then runs it from a
* single trailing line. This is the shape of `codex.cmd`.
*/
// Modern npm cmd-shim selects the interpreter through %_prog%.
const NPM_PROG_NODE_SHIM = new RegExp(
String.raw`^${ECHO_OFF}${FIND_DP0}IF EXIST ${DP0_NODE_EXE} \(\nSET "_prog=${DP0}node\.exe"\n\) ELSE \(\nSET "_prog=node"\n${PATHEXT_STRIP}\n\)\nendLocal & goto #_undefined_# 2>NUL \|\| title %COMSPEC% & "%_prog%" +${dp0Path('script')} +%\*$`,
'i'
)
/**
* Legacy `cmd-shim` and pnpm's `@zkochan/cmd-shim`: the same script spelled once
* per interpreter branch.
*/
// Legacy npm and pnpm repeat the script in both interpreter branches.
const BRANCHED_NODE_SHIM = new RegExp(
String.raw`^(?:@SETLOCAL\n)?${NODE_PATH_BLOCK}@?IF EXIST ${DP0_NODE_EXE} \(\n${DP0_NODE_EXE} +${dp0Path('script')} +%\*\n\) ELSE \(\n(?:@?SETLOCAL\n)?@?${PATHEXT_STRIP}\nnode +${dp0Path('scriptElse')} +%\*\n\)$`,
'i'
)
/** `cmd-shim` for a target that needs no interpreter (a bundled `.exe`). */
// npm shims for native executables need no interpreter.
const NPM_DIRECT_SHIM = new RegExp(
String.raw`^${ECHO_OFF}(?:${FIND_DP0})?${dp0Path('target')} +%\*$`,
'i'
)
/** pnpm's equivalent, which omits `@echo off` and keeps only `@SETLOCAL`. */
// pnpm native-executable shims may omit echo-off.
const PNPM_DIRECT_SHIM = new RegExp(String.raw`^(?:@SETLOCAL\n)?@?${dp0Path('target')} +%\*$`, 'i')
// `:` matters as much as the operators: `win32.isAbsolute('D:evil.js')` is
// false, but `win32.resolve` reads the drive letter and lands on `D:\evil.js`,
// outside the shim directory entirely.
//
// Rejecting every `:` is provably free rather than merely untested: Windows
// reserves the character in a path segment, so a relative path cannot contain
// one at all. The only spellings that can are drive-qualified (`D:x`), an
// alternate data stream (`a.js:zone`), or a `\\?\` device path — and the last
// is already refused as absolute. No generator can emit a shim-relative path
// this rule would wrongly refuse.
// Reject drive-relative paths and alternate streams: isAbsolute alone permits D:evil.js and a.js:zone.
const UNSAFE_SHIM_PATH = /[%^&|<>":\r\n]/
/** A target with no interpreter must be something CreateProcess can start on
* its own. Extensionless or `.cmd` targets need cmd's own PATHEXT search, which
* is exactly the hop being removed. */
// Extensionless and batch targets need cmd PATHEXT lookup, so cannot bypass cmd.exe.
const DIRECT_TARGET_EXTENSIONS = ['.exe', '.com']
export type ParsedWindowsCmdShim =
| { kind: 'node'; script: string; nodePathPrefix?: string }
| { kind: 'direct'; target: string }
/**
* A captured path must be relative to the shim's own directory and free of the
* characters that mean we misread the file — `%` is an unexpanded variable, the
* rest are cmd operators we are not emulating.
*/
function isPlainRelativePath(spelled: string): boolean {
return !UNSAFE_SHIM_PATH.test(spelled) && !win32.isAbsolute(spelled)
}
/** Collapse a shim to comparable text: shims differ only in line endings,
* indentation and blank lines between generators and versions. */
function canonicalize(contents: string): string {
return contents
.replace(/^\uFEFF/, '')
@@ -111,11 +61,7 @@ function canonicalize(contents: string): string {
.join('\n')
}
/**
* Recognise a generated shim. Pure, so the shapes are testable off Windows.
*
* Returns paths exactly as the shim spells them, relative to its own directory.
*/
/** Returns paths as spelled in the shim, relative to its directory. */
export function parseWindowsCmdShim(contents: string): ParsedWindowsCmdShim | null {
const canonical = canonicalize(contents)
@@ -126,8 +72,7 @@ export function parseWindowsCmdShim(contents: string): ParsedWindowsCmdShim | nu
const branched = BRANCHED_NODE_SHIM.exec(canonical)?.groups
if (branched?.script) {
// Both branches must name the same script; if they differ we matched a file
// that only looks like a shim.
// Divergent branches are not a shim we can safely replace.
if (branched.script !== branched.scriptElse || !isPlainRelativePath(branched.script)) {
return null
}
@@ -135,8 +80,7 @@ export function parseWindowsCmdShim(contents: string): ParsedWindowsCmdShim | nu
if (nodePath === undefined) {
return { kind: 'node', script: branched.script }
}
// The else branch must be exactly "prefix, then whatever was there", or the
// prepend we would reproduce is not the one the shim performs.
// Only reproduce an exact NODE_PATH prepend, without variable expansion.
if (nodePath.includes('%') || branched.nodePathElse !== `${nodePath};%NODE_PATH%`) {
return null
}
@@ -158,39 +102,11 @@ type ParseCacheEntry = {
parsed: ParsedWindowsCmdShim | null
}
/** Shim bodies do not change between spawns, but an upgrade rewrites them —
* hence the mtime/size half of the key. */
// mtime and size invalidate parsed bodies after package upgrades.
const parseCache = new Map<string, ParseCacheEntry>()
const PARSE_CACHE_LIMIT = 256
/**
* The interpreter each shim directory resolves to, keyed by that directory and
* the PATH that was searched.
*
* Why cached at all: the parse cache spares the shim read but not this walk, so
* a 30-entry PATH cost 30 `stat`s on every spawn of an already-parsed shim —
* synchronous I/O on `resolveSpawn`, where one dead network mount in PATH
* blocks the calling thread each time.
*
* Held for the process's life, and the two stale directions are treated
* differently on purpose:
*
* - A stale `null` is left alone. A `node.exe` installed after the first probe
* is not picked up until restart, which only means the working cmd.exe
* fallback stays in use — and re-probing to catch that is the whole cost this
* cache exists to avoid.
* - A stale path is revalidated on every hit, because it is the direction that
* breaks. `resolveSpawn` would otherwise keep returning an interpreter that
* has since been uninstalled, failing the spawn with ENOENT where an uncached
* process falls back to cmd.exe and succeeds. Verified by execution: without
* the check, deleting the cached `node.exe` still yielded its path.
*
* Known gap: PATH is in the key, so a caller that rebuilds `env` per spawn with
* a varying PATH — a per-worktree `.bin` prepended, say — misses every time and
* pays the full walk. Correct, just no faster than before. The 256 cap and the
* wholesale clear mean those misses cost memory nothing, so this is a bound on
* who benefits rather than a leak.
*/
// Cache interpreter lookup to avoid a synchronous PATH walk on every spawn.
const nodeCache = new Map<string, string | null>()
const NODE_CACHE_LIMIT = 256
@@ -204,12 +120,7 @@ function statFile(path: string): Stats | null {
}
function readParsedShim(program: string): ParsedWindowsCmdShim | null {
// This `stat` puts synchronous I/O on the spawn path, where `resolveSpawn`
// previously had none: a shim on an unresponsive network share now blocks the
// caller's thread until the filesystem gives up. Judged acceptable because a
// `.cmd` on such a share was already about to be spawned from it. It is the
// only such cost paid when nothing resolves — the interpreter lookup runs
// only for a shim that already parsed, and is itself cached.
// A synchronous stat can block on network shares; interpreter probes run only for recognized shims.
const stats = statFile(program)
if (!stats || stats.size > MAX_SHIM_BYTES) {
return null
@@ -225,10 +136,6 @@ function readParsedShim(program: string): ParsedWindowsCmdShim | null {
return null
}
const parsed = parseWindowsCmdShim(contents)
// Clearing wholesale drops hot entries with cold ones, where an LRU would
// not. Left as is because the cap is per-process and one entry per distinct
// `.cmd` path Orca ever spawns; reaching it means a re-read, not a wrong
// answer.
if (parseCache.size >= PARSE_CACHE_LIMIT) {
parseCache.clear()
}
@@ -242,57 +149,22 @@ function firstEnvKey(env: NodeJS.ProcessEnv, name: string): string | undefined {
return Object.keys(env).find((key) => key.toLowerCase() === lower && env[key] !== undefined)
}
/** cmd's own default when PATHEXT is unset or empty. The order is the point:
* `.COM` outranks `.EXE`, so a `node.com` is what cmd runs even with a
* `node.exe` sitting beside it. */
// Preserve cmd PATHEXT order: node.com outranks node.exe.
const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC'
/**
* The interpreter the shim itself would pick: a `node.exe` beside it, else
* whatever a bare `node` resolves to on PATH.
*
* The sibling test is exact, matching the shim's own literal
* `IF EXIST "%~dp0\node.exe"`. The PATH scan follows cmd's rule instead: the
* first directory holding ANY PATHEXT spelling of `node` wins, and inside that
* directory PATHEXT order decides. So a `node.com` early on PATH beats a
* `node.exe` later, and only the `.exe` outcome is one we can start directly.
* Every other winning spelling returns null and keeps the cmd.exe path, which a
* `.bat`/`.cmd` node would have needed anyway.
*
* Scanning past a non-`.exe` winner to a `node.exe` further down PATH is the
* tempting shortcut and the one bug this module must never have: it silently
* runs a different binary than the shim does. Every decision here stays a
* strict subset of what cmd.exe would pick, or gives up.
*
* Not searched: the working directory, which cmd would consult first for a bare
* name. Preferring a `node.exe` that happens to sit in the cwd over the
* installed one is a Windows footgun, not a behaviour worth reproducing.
*/
/** Prefer sibling node.exe, then cmd PATH/PATHEXT order; deliberately exclude cwd lookup. */
function resolveShimNode(directory: string, env: NodeJS.ProcessEnv): string | null {
const pathKey = firstEnvKey(env, 'PATH')
const pathValue = (pathKey ? env[pathKey] : undefined) ?? ''
const pathExtKey = firstEnvKey(env, 'PATHEXT')
const pathExtValue = (pathExtKey ? env[pathExtKey] : undefined) || DEFAULT_PATHEXT
// All three inputs are in the key because all three decide the answer: the
// shim prefers its own directory, falls back to PATH, and PATHEXT orders the
// spellings tried within each PATH entry. An edit to any of them therefore
// misses rather than serving the previous interpreter. Newlines separate them
// because Windows allows one in none of the three.
const key = `${directory}\n${pathValue}\n${pathExtValue}`
const cached = nodeCache.get(key)
// A hit is confirmed still on disk before it is used, because the two stale
// directions are not symmetric. A stale `null` is safe and stays uncorrected:
// it keeps the cmd.exe fallback, which works. A stale path is not — handing
// `resolveSpawn` a `node.exe` that has since been uninstalled (or dropped
// from PATH by a version manager) fails the spawn with ENOENT, where an
// uncached process would have fallen back and succeeded. One `stat` instead
// of one per PATH entry, so the walk this cache exists to skip is still skipped.
// Revalidate cached paths after uninstall; cached null safely retains the cmd.exe fallback.
if (cached !== undefined && (cached === null || statFile(cached))) {
return cached
}
const resolved = probeShimNode(directory, pathValue, pathExtValue)
// Same wholesale eviction as the parse cache, for the same reason: the cap is
// per-process and one entry per distinct shim directory Orca ever spawns from.
if (nodeCache.size >= NODE_CACHE_LIMIT) {
nodeCache.clear()
}
@@ -311,17 +183,11 @@ function probeShimNode(directory: string, pathValue: string, pathExtValue: strin
.filter((extension) => extension.startsWith('.'))
for (const entry of pathValue.split(';')) {
const trimmed = entry.trim().replace(/^"(.*)"$/, '$1')
// A relative PATH entry resolves against the child's working directory, so
// we cannot answer it here.
// Relative PATH entries depend on the child cwd, which is unknown here.
if (!trimmed || !win32.isAbsolute(trimmed)) {
continue
}
// Why every spelling and not just `.exe`: cmd stops at the first directory
// holding any of them, so passing over a `node.com` here and matching a
// `node.exe` further down PATH would run a binary the shim never would.
// Costs one `stat` per PATHEXT entry per node-less directory, paid once per
// process because of the cache above -- where the per-spawn walk it
// replaced paid its own stats on every single spawn.
// Stop at any PATHEXT winner; skipping node.com for a later node.exe would run the wrong binary.
for (const extension of extensions) {
const candidate = win32.join(trimmed, `node${extension}`)
if (!statFile(candidate)) {
@@ -341,21 +207,13 @@ function withNodePath(env: NodeJS.ProcessEnv, prefix: string): NodeJS.ProcessEnv
}
export type WindowsCmdShimResolution = {
/** Executable to spawn in place of the `.cmd`. */
program: string
/** Arguments the shim inserts ahead of the caller's own argv. */
prefixArgs: readonly string[]
/** Set only when the shim itself mutates the environment (pnpm's NODE_PATH). */
env?: NodeJS.ProcessEnv
}
/**
* Resolve `program` to the executable its shim body would have launched, or
* null to keep the `cmd.exe /c` path.
*
* `env` is the environment the child will actually receive, because both the
* PATH lookup for `node` and the NODE_PATH prepend depend on it.
*/
/** Pass the actual child environment for PATH lookup and NODE_PATH prepending; null keeps cmd.exe. */
export function resolveWindowsCmdShim(
program: string,
env: NodeJS.ProcessEnv
@@ -364,8 +222,7 @@ export function resolveWindowsCmdShim(
if (disableKey && env[disableKey]) {
return null
}
// A relative program is resolved against the child's working directory, which
// is the caller's to decide, not ours to guess.
// Relative programs depend on the child cwd, which is unknown here.
if (!win32.isAbsolute(program)) {
return null
}
@@ -375,7 +232,6 @@ export function resolveWindowsCmdShim(
}
const directory = win32.dirname(program)
// `resolve` collapses the `..` hops and the doubled separator `%dp0%\` leaves.
if (parsed.kind === 'direct') {
const target = win32.resolve(directory, parsed.target)
const lower = target.toLowerCase()
@@ -27,9 +27,6 @@ describe('quoteWindowsArgument', () => {
})
it('writes an embedded quote as "" so cmd quote parity stays even', () => {
// Why this exact assertion: `\"` also decodes to a literal quote, but it
// leaves cmd one quote short, and every later `&`/`|` on the line then
// parses as an operator instead of data.
expect(quoteWindowsArgument('c"d')).toBe('"c""d"')
expect(quoteParity(quoteWindowsArgument('c"d'))).toBe(0)
})
@@ -48,7 +45,6 @@ describe('buildWindowsCmdShimCommandLine', () => {
})
it('breaks every percent out of the quoted run', () => {
// `%VAR%` expands even inside quotes, so the pair must not survive intact.
const line = buildWindowsCmdShimCommandLine('C:\\bin\\agent.cmd', ['e%F%g'])
expect(line).toContain('"^%"')
expect(line).not.toMatch(/%F%/)
@@ -56,8 +52,6 @@ describe('buildWindowsCmdShimCommandLine', () => {
it('pins the cmd flags that make the encoding safe', () => {
const line = buildWindowsCmdShimCommandLine('C:\\bin\\agent.cmd', ['x'])
// /d: no registry AutoRun. /v:off: `!` stays literal regardless of the
// Command Processor default. /s: strip exactly the outer quote pair.
expect(line.startsWith('/d /v:off /s /c "')).toBe(true)
expect(line.endsWith('"')).toBe(true)
})
@@ -87,9 +81,7 @@ describe('line breaks', () => {
['carriage return in an argument', 'agent', ['fix\rthis']],
['newline in the program path', 'C:\\a\nb.cmd', []]
])('rejects %s rather than truncating it', (_case, program, args) => {
// cmd ends the command at a raw CR/LF whatever the quote state, so an
// encoded newline silently truncates the argument and can leave the
// remainder to run as a further command.
// cmd terminates at raw CR/LF even inside quotes; the remainder could become another command.
expect(() => buildWindowsCmdShimCommandLine(program, args)).toThrow(/line break/)
})
})
@@ -1,36 +1,6 @@
/**
* Windows command-line construction.
*
* Two parsers read the line Orca hands to CreateProcess, and they disagree:
*
* - The target program's own startup code (`CommandLineToArgvW`, which the CRT and
* Node both use) understands `\"` as a literal quote and `\\` as a literal
* backslash.
* - `cmd.exe` — unavoidable for a `.cmd`/`.bat` target — does NOT. It counts `"`
* naively to track quote state, and inside that state it still expands `%VAR%`.
*
* So a `\"` written for the first parser silently flips cmd's quote parity, and
* every later `&` `|` `<` `>` on the line stops being data and becomes an
* operator. Measured on Windows 11 with a real `.cmd` shim: `["a b", 'c"d',
* "e%F%g", "h&i", "j^k"]` came back as `["a b", 'c"d', "e^%F^%g", "h"]` — the
* `&` truncated the argument AND ran the remainder as a command.
*
* The encoding below satisfies both parsers at once and round-trips all of the
* adversarial cases; see windows-command-line.test.ts for the corpus.
*/
// cmd counts quotes naively and expands %VAR%; quoting must also survive CommandLineToArgvW.
/**
* Quote one argument for `CommandLineToArgvW`, writing an embedded `"` as `""`
* rather than `\"`.
*
* Why `""` and not `\"`: both spellings decode to a literal quote, but only `""`
* leaves cmd's naive quote count even. Since the same encoding has to survive a
* cmd hop for `.cmd` targets, we pay the (identical-length) `""` spelling
* everywhere rather than keep two dialects.
*
* Backslashes are still doubled before a quote and at the end of a quoted run,
* because that part `CommandLineToArgvW` does interpret.
*/
// Doubled quotes decode literally while preserving cmd quote parity; backslashes still need doubling.
function quoteWindows(value: string, escapePercent: boolean): string {
if (!(escapePercent ? /[\\"%]/ : /[\\"]/).test(value)) {
return `"${value}"`
@@ -43,17 +13,12 @@ function quoteWindows(value: string, escapePercent: boolean): string {
continue
}
if (char === '"') {
// The backslash run is literal, so double it; the quote itself becomes `""`.
quoted += `${'\\'.repeat(backslashes * 2)}""`
backslashes = 0
continue
}
if (escapePercent && char === '%') {
// `%VAR%` expands even inside a quoted token, so the pair has to be
// broken: close the quote, escape the percent, reopen. The backslash run
// must be DOUBLED first -- a quote straight after a single backslash is
// an escaped quote to CommandLineToArgvW, which silently corrupts every
// `C:\Users\%USERNAME%\...` path.
// Break %VAR% expansion outside quotes; double backslashes before the inserted closing quote.
quoted += `${'\\'.repeat(backslashes * 2)}"^%"`
backslashes = 0
continue
@@ -61,32 +26,21 @@ function quoteWindows(value: string, escapePercent: boolean): string {
quoted += `${'\\'.repeat(backslashes)}${char}`
backslashes = 0
}
// Trailing backslashes precede the closing quote, so they need doubling too —
// otherwise `C:\dir\` ends the argument with an escaped quote and swallows it.
// Double trailing backslashes so the closing quote cannot become escaped.
return `${quoted}${'\\'.repeat(backslashes * 2)}"`
}
/** Quote one argument for `CommandLineToArgvW`. */
/** Uses CommandLineToArgvW quoting. */
export function quoteWindowsArgument(value: string): string {
return quoteWindows(value, false)
}
/**
* Quote one argument that has to survive a cmd hop as well.
*
* Separate from `quoteWindowsArgument` rather than a boolean parameter: the
* two differ only in whether `%` is neutralised, and a flag there is easy to
* pass by accident — `values.map(quoteWindowsArgument)` hands `map`'s index in
* as the flag, which is exactly how this was first written.
*/
// A separate function prevents Array.map indices from accidentally enabling percent escaping.
export function quoteWindowsCmdArgument(value: string): string {
return quoteWindows(value, true)
}
/**
* Decode a command line the way `CommandLineToArgvW` does, e.g. one read back from
* the process table. `""` inside a quoted run yields a literal quote.
*/
/** Parses CommandLineToArgvW rules, including doubled quotes inside quoted runs. */
export function parseWindowsCommandLine(line: string): string[] {
const argv: string[] = []
let current = ''
@@ -143,8 +97,7 @@ export function parseWindowsCommandLine(line: string): string[] {
return argv
}
/** Thrown before spawn when a `.cmd`/`.bat` target would receive a line break. Typed so
* callers can tell the user the batch file is the limit, not the agent. */
/** Distinguishes cmd.exe line-break limits from agent failures. */
export class WindowsCmdLineBreakError extends Error {
constructor() {
super('cmd.exe cannot receive an argument containing a line break')
@@ -161,32 +114,17 @@ export function validateWindowsCmdArguments(values: readonly string[]): void {
}
}
/**
* Build the argv Node should spawn to run `program` with `args` through
* `cmd.exe`, for targets cmd must interpret (`.cmd`, `.bat`).
*
* Callers must pass the result with `windowsVerbatimArguments: true` so Node
* hands the line through untouched, and must NOT set `shell: true` — that
* concatenates arguments without escaping (Node warns DEP0190) and silently
* disables `windowsHide`.
*
* `/d` skips AutoRun commands from the registry, which would otherwise run
* arbitrary user configuration before our command. `/v:off` pins delayed
* expansion off so a `!` in an argument stays literal even where the Command
* Processor registry default turns it on. `/s` makes cmd strip exactly the
* outer quote pair and treat the rest verbatim.
*/
/** Requires windowsVerbatimArguments:true and shell:false to preserve quoting and windowsHide. */
export function buildWindowsCmdShimCommandLine(program: string, args: readonly string[]): string {
validateWindowsCmdArguments([program, ...args])
// The program path needs the same treatment as the arguments: it is just as
// likely to contain `%USERNAME%`, and cmd expands it just the same.
// Program paths can contain percent variables too.
const inner = [program, ...args].map(quoteWindowsCmdArgument).join(' ')
// Disable AutoRun and delayed expansion; /s strips only the outer quote pair.
return `/d /v:off /s /c "${inner}"`
}
const CMD_INTERPRETED_EXTENSIONS = ['.cmd', '.bat']
/** Whether `program` is a target Windows can only start through `cmd.exe`. */
export function isCmdInterpretedProgram(program: string): boolean {
const lower = program.toLowerCase()
return CMD_INTERPRETED_EXTENSIONS.some((extension) => lower.endsWith(extension))
@@ -1,20 +1,7 @@
// Why win32 and not the host `join`: these are Windows paths and are only ever spawned on Windows,
// but they are also built off-platform (tests, and any code that plans a Windows command from a
// POSIX host), where the host separator produces the mixed `C:\Windows/System32/whoami.exe`.
// Use Windows separators even when planning commands or testing from a POSIX host.
import { win32 as pathWin32 } from 'node:path'
/**
* Absolute paths for the Windows system binaries Orca shells out to.
*
* Why not a bare name: a bare `powershell.exe` is resolved against the child's
* PATH, and Orca's PATH under Electron is not the user's. Where a policy has
* pruned or shadowed the System32 entry the spawn simply fails, and the caller
* reports the wrong thing — the font picker silently fell back to five
* hardcoded families rather than saying it could not enumerate (#11771).
*
* `SystemRoot` is set on every Windows session; the literal is only a last
* resort for a stripped environment.
*/
// Absolute system paths avoid stripped or shadowed Electron PATH entries.
function systemRoot(env: NodeJS.ProcessEnv = process.env): string {
return env.SystemRoot ?? env.SYSTEMROOT ?? env.windir ?? 'C:\\Windows'
}
@@ -82,7 +82,6 @@ describe('windows tree kill tracker', () => {
launched[0].emit('close', 0)
await expect(secondWait).resolves.toBe(true)
expect(inFlightWindowsTreeKill(child)).toBeUndefined()
// A proven kill stays proven: no second taskkill against a pid Windows may reissue.
expect(startWindowsTreeKill(child, 1234, vi.fn())).toBe(first)
expect(launch).toHaveBeenCalledOnce()
})
@@ -133,7 +132,6 @@ describe('windows tree kill tracker', () => {
await expect(kill.settled).resolves.toBe('unverifiable')
expect(launched[0].kill).toHaveBeenCalledOnce()
expect(killRoot).toHaveBeenCalledOnce()
// A late success after abandonment does not rewrite the verdict.
launched[0].emit('close', 0)
expect(kill.verdict()).toBe('unverifiable')
})
@@ -2,30 +2,14 @@ import { spawn as nodeSpawn, type ChildProcess } from 'node:child_process'
import { constants, getPriority, setPriority } from 'node:os'
import { windowsSystem32Binary } from './windows-system-binary'
/**
* `live` while taskkill is still walking the tree, `exited` once taskkill
* reported the whole tree terminated, `unverifiable` when it failed or ran out
* of time. Nothing short of a successful taskkill is reported as `exited`.
*/
/** Only a successful taskkill proves the entire tree exited. */
export type WindowsTreeKillVerdict = 'live' | 'unverifiable' | 'exited'
export type SettledWindowsTreeKillVerdict = Exclude<WindowsTreeKillVerdict, 'live'>
/** How long one caller waits before treating the tree as unverifiable. */
export const WINDOWS_TASKKILL_WAIT_MS = 5_000
/**
* How long the tracked taskkill may keep walking after its caller stops waiting.
* Why past the wait: loaded windows-2022 runners measured 4.6-18 s, and killing
* the root before taskkill reads it orphans the descendants it was about to
* find. Bounded because a live root keeps its git admission slot until it exits.
*/
// Outlive the caller wait: killing the root before taskkill reads it can orphan descendants.
export const WINDOWS_TASKKILL_DEADLINE_MS = 30_000
/**
* How long taskkill may keep running once the root has exited.
* Why short: Node then releases the root's handle, so Windows may reissue the
* pid (#10680) to a process a late-starting taskkill would walk instead. Within
* this window a taskkill that itself killed the root still reports success;
* past it the kill is abandoned as unverifiable. No longer than the old cap.
*/
// Bound taskkill after root exit: Windows may reuse the PID once Node releases its handle.
export const WINDOWS_TASKKILL_AFTER_ROOT_EXIT_MS = 2_000
export type WindowsTreeKill = {
@@ -47,13 +31,11 @@ let launchTaskkill: TaskkillLauncher = launchSystemTaskkill
// Keyed by handle, not pid: a reused pid must never join another tree's kill.
const treeKills = new WeakMap<ChildProcess, WindowsTreeKill>()
/** The tree kill already in flight or proven for this child, if any. */
export function trackedWindowsTreeKill(child: ChildProcess): WindowsTreeKill | undefined {
const kill = treeKills.get(child)
return kill && kill.verdict() !== 'unverifiable' ? kill : undefined
}
/** Settles when this child's in-flight tree kill does; undefined when none is running. */
export function inFlightWindowsTreeKill(
child: ChildProcess
): Promise<SettledWindowsTreeKillVerdict> | undefined {
@@ -61,14 +43,7 @@ export function inFlightWindowsTreeKill(
return kill?.verdict() === 'live' ? kill.settled : undefined
}
/**
* Start one tracked `taskkill /t /f` for this child's tree.
*
* The taskkill outlives every caller's wait and is abandoned only at the
* deadline or shortly after the root exits; the root is killed by handle only
* after taskkill has failed or been abandoned, never while it may still be
* reading the tree.
*/
/** Delay the root fallback until taskkill fails or its deadline expires. */
export function startWindowsTreeKill(
child: ChildProcess,
rootPid: number,
@@ -140,7 +115,7 @@ export function startWindowsTreeKill(
return kill
}
/** True when the tree is proven dead within this caller's wait; the kill keeps running either way. */
/** The tracked kill continues even if this caller stops waiting. */
export function waitForWindowsTreeKill(
kill: WindowsTreeKill,
waitMs = WINDOWS_TASKKILL_WAIT_MS
@@ -162,7 +137,7 @@ function childProcessExited(child: ChildProcess): boolean {
return (child.exitCode ?? null) !== null || (child.signalCode ?? null) !== null
}
/** Test seam: swap the taskkill launch, e.g. for one that starts late like a loaded host's. */
/** Simulates taskkill delays seen on loaded Windows hosts. */
export function _setWindowsTaskkillLauncherForTests(launcher: TaskkillLauncher | null): void {
launchTaskkill = launcher ?? launchSystemTaskkill
}