mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(windows): resolve npm/pnpm .cmd shims past cmd.exe (#17869)
* fix(windows): resolve npm/pnpm .cmd shims past cmd.exe
A `.cmd` target forces every spawn through `cmd.exe /c` with each argument
caret-escaped, and Microsoft Defender for Endpoint scores a long `cmd.exe /c`
line carrying caret-escaped natural language as obfuscation. `codex.cmd` is
named in the spawn cluster of the MDE incident this addresses.
npm's `cmd-shim` and pnpm's `@zkochan/cmd-shim` generate files whose whole body
is "find node, run this script". Read one, and the spawn can go straight to
`node.exe <script> <args>` — no cmd.exe, no caret escaping. Anything the parser
does not recognise exactly, or whose target cannot be confirmed on disk, keeps
the existing cmd.exe path.
Incidentally fixes a real bug: cmd ends its command at a raw CR/LF whatever the
quote state, so a multi-line agent prompt through a `.cmd` shim had to be
rejected. Resolved shims have no such limit.
* fix(windows): refuse drive-relative shim paths and run the win32 tests in CI
Two blocking findings from review.
A drive-relative path defeated the absolute-path guard:
`win32.isAbsolute('D:evil.js')` is false, but `win32.resolve` reads the drive
letter and lands on `D:\evil.js`, outside the shim directory. cmd would have
built `C:\shim\D:evil.js` and failed; we would have executed the wrong file.
Adding `:` to the unsafe-character set closes it, and the alternate-data-stream
spelling `a.js:zone` with it. It costs no coverage: 84 of the 91 real shims on
this box still resolve, the same seven fall back.
Neither `windows-cmd-shim-resolution.test.ts` nor its `.win32` sibling was in
the Windows package job's file list, so the whole filesystem/resolution half and
the real-spawn equivalence suite ran nowhere. Both are now in
`WINDOWS_PACKAGE_TESTS` and in the pr.yml step.
Also from review: clear `windowsVerbatimArguments` explicitly on the resolved
branch rather than inheriting it, since there is no caller-built command line
there; document the kill switch and the PTY/hook-wrapper scope limits in
docs/reference; and cover drive-relative, BOM, line-ending, casing and `%*`
tampering in the platform-independent half of the tests.
* docs(windows): justify the shim-path colon guard from the filesystem rule
The guard was argued empirically ("none of the 91 shims on this box has one"),
which invites a future reader to relax it for a shim we have not seen. Windows
reserves `:` within a path segment, so a relative path cannot carry one at all:
the only spellings that can are drive-qualified, an alternate data stream, or a
`\?\` device path, and the last is already refused as absolute. That makes a
false refusal impossible rather than unobserved.
* refactor(child-process): move resolveSpawn into its own module
The merge with main pushed run-process.ts one line past the 300-line cap:
both sides grew it. The spawn-argv decision is already a pure, separately
tested unit, so it moves out rather than the cap moving up. run-process.ts
re-exports it, so no caller changes.
* perf(child-process): cache the shim interpreter lookup
The parse cache spared the shim read but not the PATH walk, so a second
resolution of the same .cmd did 0 reads and one statSync per PATH entry --
30 on a 30-entry PATH, synchronous on resolveSpawn, where one dead network
mount blocks the calling thread on every spawn.
Keyed by shim directory AND PATH, since the shim's own rule is
%~dp0\node.exe first then PATH, and a PATH edit between spawns must miss.
Corrects the stat comment, which accounted only for the shim itself.
* fix(child-process): revalidate a cached shim interpreter before using it
The node cache was held for process life and never rechecked, so a cached
node.exe that was later uninstalled -- or dropped from PATH by a version
manager -- was still handed to resolveSpawn, failing the spawn with ENOENT.
An uncached process in the same state returns null and falls back to
cmd.exe successfully, so the cache was strictly worse than no cache.
One statSync on a non-null hit, not one per PATH entry, so the walk this
cache exists to skip is still skipped. The stale-null direction stays
uncorrected on purpose: it only keeps the working cmd.exe fallback. Both
directions are now stated in the comment, along with the known miss for
callers that vary PATH per spawn.
* fix(child-process): honour PATHEXT when resolving the shim interpreter
The doc claimed a node.com/.bat/.cmd on PATH returned null and fell back to
cmd.exe. The scan actually skipped those entries and kept looking for a
node.exe, so PATH=C:\A;C:\B with C:\A\node.com and C:\B\node.exe resolved to
B's node.exe while the shim runs A's node.com -- a different binary, chosen
silently, on the one axis this module must not get wrong.
The scan now follows cmd's rule: first PATH directory holding any PATHEXT
spelling wins, PATHEXT order decides within it, and only an .exe winner is
returned. Anything else gives up and keeps the cmd.exe path, which restores
the strict-subset-of-cmd property everywhere except the documented cwd case.
PATHEXT is read from the child's env and joined into the cache key, since it
now changes the answer. Costs one stat per PATHEXT entry per node-less
directory, paid once per process behind the cache.
---------
Co-authored-by: Orca Worker <orca-worker@localhost>
This commit is contained in:
@@ -847,6 +847,8 @@ jobs:
|
||||
src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts
|
||||
src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts
|
||||
src/shared/child-process/windows-command-line.win32.test.ts
|
||||
src/shared/child-process/windows-cmd-shim-resolution.test.ts
|
||||
src/shared/child-process/windows-cmd-shim-resolution.win32.test.ts
|
||||
src/main/agent-hooks/windows-hook-payload-delivery.test.ts
|
||||
src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts
|
||||
src/main/windows/windows-pty-job.win32.test.ts
|
||||
|
||||
@@ -110,6 +110,7 @@ docs/**
|
||||
!docs/reference/macos-press-and-hold.md
|
||||
!docs/reference/orcad-operations.md
|
||||
!docs/reference/relay-grace-time-reconfiguration.md
|
||||
!docs/reference/windows-cmd-shim-resolution.md
|
||||
!docs/reference/windows-daemon-host-relocation.md
|
||||
!docs/reference/windows-edr-posture.md
|
||||
!docs/reference/windows-process-enumeration.md
|
||||
|
||||
@@ -53,7 +53,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
|
||||
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
|
||||
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
|
||||
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
|
||||
- **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. A ratchet test fails on any new direct import.
|
||||
- **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. A ratchet test fails on any new direct import. Recognised npm/pnpm `.cmd` shims are resolved to their real target so the spawn skips `cmd.exe` entirely; see [`docs/reference/windows-cmd-shim-resolution.md`](./docs/reference/windows-cmd-shim-resolution.md) before adding a shim shape or debugging one.
|
||||
- **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. See [`docs/reference/windows-process-enumeration.md`](./docs/reference/windows-process-enumeration.md).
|
||||
- **Windows daemon-host relocation**: the terminal daemon runs from a copy of the app runtime under `%LOCALAPPDATA%`, which is what survives an auto-update. Before touching that copy, its exe name, or the NSIS uninstall macro, read [`docs/reference/windows-daemon-host-relocation.md`](./docs/reference/windows-daemon-host-relocation.md).
|
||||
- **Windows EDR signal**: don't add `-ExecutionPolicy Bypass`, `-EncodedCommand`, `cmd.exe /c` with escaped free text, per-operation interpreter spawning, or runtime `Add-Type` compilation without reading [`docs/reference/windows-edr-posture.md`](./docs/reference/windows-edr-posture.md) first — behavioural EDR scores each of those, and being signed does not clear them.
|
||||
|
||||
@@ -217,6 +217,8 @@ const WINDOWS_PACKAGE_TESTS = [
|
||||
'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts',
|
||||
'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts',
|
||||
'src/shared/child-process/windows-command-line.win32.test.ts',
|
||||
'src/shared/child-process/windows-cmd-shim-resolution.test.ts',
|
||||
'src/shared/child-process/windows-cmd-shim-resolution.win32.test.ts',
|
||||
'src/main/agent-hooks/windows-hook-payload-delivery.test.ts',
|
||||
'src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts',
|
||||
'src/main/windows/windows-pty-job.win32.test.ts',
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Resolving Windows `.cmd` shims past cmd.exe
|
||||
|
||||
Node refuses to spawn a `.cmd`/`.bat` target without a shell (the
|
||||
CVE-2024-27980 mitigation), so `resolveSpawn` has to make `cmd.exe` the program
|
||||
and hand it `/d /v:off /s /c "<caret-escaped argv>"`. For an agent CLI that
|
||||
means a long `cmd.exe /c` line whose caret-escaped payload is natural-language
|
||||
prompt text — which Microsoft Defender for Endpoint's command-line model scores
|
||||
as obfuscation. `codex.cmd` appeared in the spawn cluster of an MDE incident
|
||||
against Orca for exactly this reason.
|
||||
|
||||
`src/shared/child-process/windows-cmd-shim-resolution.ts` sidesteps it. npm's
|
||||
`cmd-shim` and pnpm's `@zkochan/cmd-shim` generate files whose entire body is
|
||||
"find a Node interpreter and run this script". Reading one lets `resolveSpawn`
|
||||
spawn `node.exe <script> <args…>` directly: no cmd.exe in the tree, and no
|
||||
caret escaping at all.
|
||||
|
||||
## What resolution changes
|
||||
|
||||
Only `runProcess` / `spawnProcess` callers. Two things people expect it to
|
||||
cover, and it does not:
|
||||
|
||||
- **The interactive terminal.** `src/main/daemon/pty-subprocess/native-pty-spawn.ts`
|
||||
calls `pty.spawn` directly, so typing `codex` in an Orca terminal is
|
||||
completely unaffected.
|
||||
- **Orca's own hook wrappers** (`codex-hook.cmd` and friends). These are batch
|
||||
files Orca writes, matching none of the generator shapes, so they keep the
|
||||
cmd.exe path. They are addressable — we generate them — but not by this
|
||||
module.
|
||||
|
||||
## Adding a shape
|
||||
|
||||
Four shapes are recognised, each transcribed verbatim from a real install into
|
||||
`src/shared/child-process/__fixtures__/windows-cmd-shim-bodies.ts`. If you add a
|
||||
fifth, add its real body there too. A shape guessed from documentation is not
|
||||
evidence.
|
||||
|
||||
The rule for the parser is all-or-nothing: the whole canonicalised body must
|
||||
match end to end, and anything unrecognised returns null and keeps the cmd.exe
|
||||
path. **A mis-resolution silently runs the wrong program or drops arguments,
|
||||
which is far worse than an EDR alert** — when in doubt, refuse.
|
||||
|
||||
Resolution also refuses a captured path that is absolute, drive-relative
|
||||
(`D:evil.js` — `win32.isAbsolute` says false, but `win32.resolve` leaves the
|
||||
shim directory), or contains `% ^ & | < > " :` or a line break; a script or
|
||||
target that is not on disk; an interpreter-less target that is not `.exe`/`.com`;
|
||||
and a program path that is not absolute.
|
||||
|
||||
Refusing every `:` cannot cause a false refusal. Windows reserves the character
|
||||
within a path segment, so a relative path cannot contain one — the only
|
||||
spellings that can are drive-qualified, an alternate data stream (`a.js:zone`),
|
||||
or a `\\?\` device path, and the last is already refused as absolute.
|
||||
|
||||
## Kill switch
|
||||
|
||||
Set **`ORCA_DISABLE_CMD_SHIM_RESOLUTION`** to any non-empty value in the
|
||||
environment a child is spawned with, and every `.cmd` goes back through
|
||||
`cmd.exe /c` unchanged. It is read from the spawn's own environment, so
|
||||
exporting it before launching Orca disables resolution process-wide.
|
||||
|
||||
Use it to confirm a suspected mis-resolution: run the failing operation with and
|
||||
without it. Identical behaviour means resolution is not the cause. If it is,
|
||||
report the shim's body — the parser is only allowed to recognise shapes we have
|
||||
seen for real.
|
||||
|
||||
## Behaviour that changes, deliberately
|
||||
|
||||
A resolved shim is not merely a quieter spelling of the cmd.exe path. Two limits
|
||||
of `cmd.exe` disappear with it:
|
||||
|
||||
- An argument containing `\r`/`\n` was rejected outright, because cmd ends its
|
||||
command at a raw line break whatever the quote state. Multi-line agent prompts
|
||||
now work.
|
||||
- A command line over 8191 characters returned `The command line is too long.`
|
||||
Long prompts now work.
|
||||
|
||||
Both are improvements, but they are behaviour changes: an unresolved shim still
|
||||
hits both limits, so a caller must not assume every `.cmd` accepts them.
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
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`. */
|
||||
export const REAL_CODEX_CMD = crlf([
|
||||
'@ECHO off',
|
||||
'GOTO start',
|
||||
':find_dp0',
|
||||
'SET dp0=%~dp0',
|
||||
'EXIT /b',
|
||||
':start',
|
||||
'SETLOCAL',
|
||||
'CALL :find_dp0',
|
||||
'',
|
||||
'IF EXIST "%dp0%\\node.exe" (',
|
||||
' SET "_prog=%dp0%\\node.exe"',
|
||||
') ELSE (',
|
||||
' SET "_prog=node"',
|
||||
' SET PATHEXT=%PATHEXT:;.JS;=;%',
|
||||
')',
|
||||
'',
|
||||
'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. */
|
||||
export const REAL_PNX_CMD = crlf([
|
||||
'@ECHO off',
|
||||
'GOTO start',
|
||||
':find_dp0',
|
||||
'SET dp0=%~dp0',
|
||||
'EXIT /b',
|
||||
':start',
|
||||
'SETLOCAL',
|
||||
'CALL :find_dp0',
|
||||
'"%dp0%\\node_modules\\pnpm\\pnx" %*'
|
||||
])
|
||||
|
||||
const VITEST_NODE_PATH = [
|
||||
'C:\\Users\\neil\\orca\\orca\\node_modules\\.pnpm\\vitest@4.1.11\\node_modules\\vitest\\node_modules',
|
||||
'C:\\Users\\neil\\orca\\orca\\node_modules\\.pnpm\\vitest@4.1.11\\node_modules',
|
||||
'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 (',
|
||||
` @SET "NODE_PATH=${VITEST_NODE_PATH}"`,
|
||||
') ELSE (',
|
||||
` @SET "NODE_PATH=${VITEST_NODE_PATH};%NODE_PATH%"`,
|
||||
')',
|
||||
'@IF EXIST "%~dp0\\node.exe" (',
|
||||
' "%~dp0\\node.exe" "%~dp0\\..\\vitest\\vitest.mjs" %*',
|
||||
') ELSE (',
|
||||
' @SET PATHEXT=%PATHEXT:;.JS;=;%',
|
||||
' node "%~dp0\\..\\vitest\\vitest.mjs" %*',
|
||||
')'
|
||||
])
|
||||
|
||||
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. */
|
||||
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',
|
||||
...(nodePathPrefix
|
||||
? [
|
||||
'@IF NOT DEFINED NODE_PATH (',
|
||||
` @SET "NODE_PATH=${nodePathPrefix}"`,
|
||||
') ELSE (',
|
||||
` @SET "NODE_PATH=${nodePathPrefix};%NODE_PATH%"`,
|
||||
')'
|
||||
]
|
||||
: []),
|
||||
'@IF EXIST "%~dp0\\node.exe" (',
|
||||
` "%~dp0\\node.exe" "%~dp0\\${scriptRelative}" %*`,
|
||||
') ELSE (',
|
||||
' @SET PATHEXT=%PATHEXT:;.JS;=;%',
|
||||
` node "%~dp0\\${scriptRelative}" %*`,
|
||||
')'
|
||||
])
|
||||
}
|
||||
|
||||
/** The npm one-line shape around an arbitrary target. */
|
||||
export function npmDirectShim(targetRelative: string): string {
|
||||
return crlf(['@ECHO off', `"%~dp0${targetRelative}" %*`])
|
||||
}
|
||||
@@ -2,10 +2,9 @@ import {
|
||||
spawn as nodeSpawn,
|
||||
spawnSync as nodeSpawnSync,
|
||||
type ChildProcess,
|
||||
type ChildProcessWithoutNullStreams,
|
||||
type SpawnOptions as NodeSpawnOptions
|
||||
type ChildProcessWithoutNullStreams
|
||||
} from 'node:child_process'
|
||||
import { buildWindowsCmdShimCommandLine, isCmdInterpretedProgram } from './windows-command-line'
|
||||
import { resolveSpawn } from './spawn-resolution'
|
||||
import { forceTerminateProcessTree, signalProcessTree } from './process-tree-termination'
|
||||
|
||||
import { createOutputSink } from './bounded-output-sink'
|
||||
@@ -19,6 +18,7 @@ export type {
|
||||
ProcessResult
|
||||
} from './process-spec'
|
||||
export { DEFAULT_PROCESS_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES } from './process-spec'
|
||||
export { resolveSpawn, type ResolvedSpawn } from './spawn-resolution'
|
||||
import type { ProcessSpec, ProcessResult } from './process-spec'
|
||||
import { DEFAULT_PROCESS_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES } from './process-spec'
|
||||
/**
|
||||
@@ -40,54 +40,6 @@ const PROCESS_EXIT_GRACE_MS = 2_000
|
||||
*/
|
||||
const BARRIER_UNVERIFIED_EXIT_GRACE_MS = 10_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: spec.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,
|
||||
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: false,
|
||||
...(spec.terminationBarrier && platform !== 'win32' ? { detached: true } : {})
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { SpawnOptions as NodeSpawnOptions } from 'node:child_process'
|
||||
import { buildWindowsCmdShimCommandLine, isCmdInterpretedProgram } from './windows-command-line'
|
||||
import { resolveWindowsCmdShim } from './windows-cmd-shim-resolution'
|
||||
import type { ProcessSpec } from './process-spec'
|
||||
|
||||
export type ResolvedSpawn = {
|
||||
file: string
|
||||
args: readonly string[]
|
||||
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.
|
||||
*/
|
||||
export function resolveSpawn(spec: ProcessSpec, platform: NodeJS.Platform): ResolvedSpawn {
|
||||
const args = spec.args ?? []
|
||||
const base: NodeSpawnOptions = {
|
||||
cwd: spec.cwd,
|
||||
env: spec.env,
|
||||
stdio: spec.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,
|
||||
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: false,
|
||||
...(spec.terminationBarrier && platform !== 'win32' ? { detached: true } : {})
|
||||
}
|
||||
|
||||
if (platform !== 'win32' || !isCmdInterpretedProgram(spec.program)) {
|
||||
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.
|
||||
const shim = resolveWindowsCmdShim(spec.program, spec.env ?? process.env)
|
||||
if (shim) {
|
||||
return {
|
||||
file: shim.program,
|
||||
args: [...shim.prefixArgs, ...args],
|
||||
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.
|
||||
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.
|
||||
const comSpec = spec.env?.ComSpec ?? process.env.ComSpec ?? 'cmd.exe'
|
||||
return {
|
||||
file: comSpec,
|
||||
args: [buildWindowsCmdShimCommandLine(spec.program, args)],
|
||||
options: { ...base, windowsVerbatimArguments: true }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { removeTreeSync } from '../windows-transient-lock-removal'
|
||||
import { parseWindowsCmdShim, resolveWindowsCmdShim } from './windows-cmd-shim-resolution'
|
||||
import { resolveSpawn } from './run-process'
|
||||
import {
|
||||
REAL_AGENT_BROWSER_CMD,
|
||||
REAL_CODEX_CMD,
|
||||
REAL_PNPM_CMD,
|
||||
REAL_PN_CMD,
|
||||
REAL_PNX_CMD,
|
||||
REAL_VITEST_CMD,
|
||||
REAL_VITEST_NODE_PATH,
|
||||
npmDirectShim,
|
||||
npmProgNodeShim,
|
||||
pnpmBranchedNodeShim
|
||||
} from './__fixtures__/windows-cmd-shim-bodies'
|
||||
|
||||
describe('parseWindowsCmdShim', () => {
|
||||
it('reads the npm node shim MDE flagged (codex.cmd)', () => {
|
||||
expect(parseWindowsCmdShim(REAL_CODEX_CMD)).toEqual({
|
||||
kind: 'node',
|
||||
script: 'node_modules\\@openai\\codex\\bin\\codex.js'
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the pnpm two-branch shim, including its NODE_PATH prepend', () => {
|
||||
expect(parseWindowsCmdShim(REAL_VITEST_CMD)).toEqual({
|
||||
kind: 'node',
|
||||
script: '..\\vitest\\vitest.mjs',
|
||||
nodePathPrefix: REAL_VITEST_NODE_PATH
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a pnpm branch shim with no NODE_PATH block', () => {
|
||||
expect(parseWindowsCmdShim(pnpmBranchedNodeShim('..\\x\\cli.js'))).toEqual({
|
||||
kind: 'node',
|
||||
script: '..\\x\\cli.js'
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the npm and pnpm shims for a bundled executable', () => {
|
||||
expect(parseWindowsCmdShim(REAL_AGENT_BROWSER_CMD)).toEqual({
|
||||
kind: 'direct',
|
||||
target: 'node_modules\\agent-browser\\bin\\agent-browser-win32-x64.exe'
|
||||
})
|
||||
expect(parseWindowsCmdShim(REAL_PNPM_CMD)).toEqual({
|
||||
kind: 'direct',
|
||||
target:
|
||||
'..\\global\\v11\\27d0-19f7df4c136-1fab7163f1a52461\\node_modules\\@pnpm\\exe\\pnpm.exe'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a bare PATH command with no target to read', REAL_PN_CMD],
|
||||
['an arbitrary batch script', '@echo off\r\nnode "%~dp0echoargs.js" %*\r\n'],
|
||||
['a shim with extra interpreter flags', '@ECHO off\r\nnode --experimental "%~dp0a.js" %*\r\n'],
|
||||
[
|
||||
'a shim whose branches disagree about the script',
|
||||
pnpmBranchedNodeShim('..\\a.js').replace('node "%~dp0\\..\\a.js"', 'node "%~dp0\\..\\b.js"')
|
||||
],
|
||||
['a shim with anything appended after the target line', `${REAL_CODEX_CMD}echo tampered\r\n`],
|
||||
['an unexpanded variable in the target', '@ECHO off\r\n"%~dp0%TARGET%\\a.exe" %*\r\n'],
|
||||
['an empty file', '']
|
||||
])('refuses %s', (_case, contents) => {
|
||||
expect(parseWindowsCmdShim(contents)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a direct target', npmDirectShim('D:evil.exe')],
|
||||
['a node script', npmProgNodeShim('D:evil.js')],
|
||||
['a same-drive spelling', npmDirectShim('C:evil.exe')]
|
||||
])('refuses a drive-relative path in %s', (_case, contents) => {
|
||||
// `win32.isAbsolute('D:evil.js')` is false, yet `win32.resolve` reads the
|
||||
// drive letter and lands on `D:\evil.js` — outside the shim directory
|
||||
// entirely. cmd would build `C:\shim\D:evil.js` and fail, so accepting it
|
||||
// is the silent-wrong-execution case this parser exists to exclude.
|
||||
expect(parseWindowsCmdShim(contents)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['%* replaced by %1, which forwards only the first argument', '%1'],
|
||||
['%* duplicated, which forwards every argument twice', '%* %*']
|
||||
])('refuses %s', (_case, forwarding) => {
|
||||
expect(parseWindowsCmdShim(npmProgNodeShim('cli.js').replace('%*', forwarding))).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a UTF-8 BOM', (body: string) => `${body}`],
|
||||
['a doubled UTF-8 BOM', (body: string) => `${body}`],
|
||||
['LF-only line endings', (body: string) => body.replace(/\r\n/g, '\n')],
|
||||
['no final newline', (body: string) => body.replace(/\r\n$/, '')],
|
||||
['trailing whitespace on every line', (body: string) => body.replace(/\r\n/g, ' \t\r\n')],
|
||||
['doubled blank lines', (body: string) => body.replace(/\r\n/g, '\r\n\r\n')],
|
||||
['an all-lowercase body', (body: string) => body.toLowerCase()],
|
||||
['an all-uppercase body', (body: string) => body.toUpperCase()]
|
||||
])('reads the codex shim through %s', (_case, rewrite) => {
|
||||
// Line endings, casing and surrounding whitespace vary with the generator,
|
||||
// the editor and the transport; none of them changes what the shim runs.
|
||||
// Only the captured path is case-sensitive, so the case cases compare
|
||||
// against the rewritten spelling.
|
||||
const parsed = parseWindowsCmdShim(rewrite(REAL_CODEX_CMD))
|
||||
expect(parsed?.kind).toBe('node')
|
||||
expect((parsed as { script: string }).script.toLowerCase()).toBe(
|
||||
'node_modules\\@openai\\codex\\bin\\codex.js'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses lone-CR line endings, which leave the body as one unsplit line', () => {
|
||||
expect(parseWindowsCmdShim(REAL_CODEX_CMD.replace(/\r\n/g, '\r'))).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses a NODE_PATH block whose branches are not a plain prepend', () => {
|
||||
const tampered = pnpmBranchedNodeShim('..\\a.js', 'C:\\p').replace(
|
||||
'"NODE_PATH=C:\\p;%NODE_PATH%"',
|
||||
'"NODE_PATH=C:\\evil;%NODE_PATH%"'
|
||||
)
|
||||
expect(parseWindowsCmdShim(tampered)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolution reads the filesystem with win32 path semantics, so it can only run
|
||||
* here. The shape recognition above is the platform-independent half.
|
||||
*/
|
||||
const describeOnWindows = process.platform === 'win32' ? describe : describe.skip
|
||||
|
||||
describeOnWindows('resolveWindowsCmdShim', () => {
|
||||
let dir: string
|
||||
let env: NodeJS.ProcessEnv
|
||||
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'orca-shim-resolve-'))
|
||||
env = { ...process.env }
|
||||
writeFileSync(join(dir, 'cli.js'), 'process.stdout.write("hi")\n')
|
||||
writeFileSync(join(dir, 'ci2.js'), '')
|
||||
writeFileSync(join(dir, 'real.exe'), '')
|
||||
writeFileSync(join(dir, 'nested.cmd'), '')
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
removeTreeSync(dir)
|
||||
})
|
||||
|
||||
function write(name: string, contents: string): string {
|
||||
const path = join(dir, name)
|
||||
writeFileSync(path, contents)
|
||||
return path
|
||||
}
|
||||
|
||||
it('resolves the npm node shim to node.exe plus the script', () => {
|
||||
const resolved = resolveWindowsCmdShim(write('codexish.cmd', npmProgNodeShim('cli.js')), env)
|
||||
expect(resolved?.program.toLowerCase().endsWith('node.exe')).toBe(true)
|
||||
expect(resolved?.prefixArgs).toEqual([join(dir, 'cli.js')])
|
||||
expect(resolved?.env).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prefers a node.exe sitting beside the shim, as the shim itself does', () => {
|
||||
const sibling = mkdtempSync(join(tmpdir(), 'orca-shim-sibling-'))
|
||||
try {
|
||||
writeFileSync(join(sibling, 'node.exe'), '')
|
||||
writeFileSync(join(sibling, 'cli.js'), '')
|
||||
const shim = join(sibling, 'a.cmd')
|
||||
writeFileSync(shim, npmProgNodeShim('cli.js'))
|
||||
expect(resolveWindowsCmdShim(shim, env)?.program).toBe(join(sibling, 'node.exe'))
|
||||
} finally {
|
||||
removeTreeSync(sibling)
|
||||
}
|
||||
})
|
||||
|
||||
it('prepends the pnpm NODE_PATH the shim would have set', () => {
|
||||
const shim = write('pnpmish.cmd', pnpmBranchedNodeShim('cli.js', 'C:\\store\\a'))
|
||||
expect(resolveWindowsCmdShim(shim, { ...env, NODE_PATH: 'C:\\existing' })?.env?.NODE_PATH).toBe(
|
||||
'C:\\store\\a;C:\\existing'
|
||||
)
|
||||
expect(resolveWindowsCmdShim(shim, { ...env, NODE_PATH: undefined })?.env?.NODE_PATH).toBe(
|
||||
'C:\\store\\a'
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves a direct .exe target', () => {
|
||||
const shim = write('direct.cmd', npmDirectShim('real.exe'))
|
||||
expect(resolveWindowsCmdShim(shim, env)).toEqual({
|
||||
program: join(dir, 'real.exe'),
|
||||
prefixArgs: []
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a target that does not exist', 'missing', npmProgNodeShim('missing.js')],
|
||||
['an extensionless direct target, which needs cmd PATHEXT search', 'pnx', REAL_PNX_CMD],
|
||||
['a direct target that is itself a .cmd', 'nested', npmDirectShim('nested.cmd')],
|
||||
[
|
||||
'an absolute target, which the shim would not spell that way',
|
||||
'abs',
|
||||
npmDirectShim('C:\\o.exe')
|
||||
],
|
||||
[
|
||||
'a drive-relative target that would escape the shim directory',
|
||||
'drive',
|
||||
npmDirectShim('D:o.exe')
|
||||
],
|
||||
['a file that is not a generated shim', 'alias', REAL_PN_CMD]
|
||||
])('falls back for %s', (_case, name, contents) => {
|
||||
// A file per case: the resolution cache is keyed on path, mtime and size,
|
||||
// and two same-size writes in one clock tick would otherwise collide.
|
||||
expect(resolveWindowsCmdShim(write(`fallback-${name}.cmd`, contents), env)).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back for a relative program path', () => {
|
||||
write('relative.cmd', npmProgNodeShim('cli.js'))
|
||||
expect(resolveWindowsCmdShim('relative.cmd', env)).toBeNull()
|
||||
})
|
||||
|
||||
it('honours the kill switch', () => {
|
||||
const shim = write('killswitch.cmd', npmProgNodeShim('cli.js'))
|
||||
expect(resolveWindowsCmdShim(shim, env)).not.toBeNull()
|
||||
expect(
|
||||
resolveWindowsCmdShim(shim, {
|
||||
...env,
|
||||
ORCA_DISABLE_CMD_SHIM_RESOLUTION: '1'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('re-reads a shim whose mtime moved even at an identical size', () => {
|
||||
// An upgrade rewrites the shim in place; a path-only cache would keep
|
||||
// launching the previous entry point.
|
||||
const shim = write('upgraded.cmd', npmProgNodeShim('cli.js'))
|
||||
expect(resolveWindowsCmdShim(shim, env)?.prefixArgs).toEqual([join(dir, 'cli.js')])
|
||||
|
||||
// Same length as the first body, so only the mtime can invalidate it.
|
||||
writeFileSync(shim, npmProgNodeShim('ci2.js'))
|
||||
const later = new Date(Date.now() + 5_000)
|
||||
utimesSync(shim, later, later)
|
||||
expect(resolveWindowsCmdShim(shim, env)?.prefixArgs).toEqual([join(dir, 'ci2.js')])
|
||||
})
|
||||
|
||||
it('walks PATH once per shim directory, not once per spawn', () => {
|
||||
// The parse cache spares the shim read but not the interpreter walk, so an
|
||||
// already-parsed shim was still paying one `stat` per PATH entry on every
|
||||
// spawn. Proven by effect rather than by counting: `early` gains a node.exe
|
||||
// only AFTER the first resolution, so a second call that still answers
|
||||
// `late` cannot have re-walked PATH. Delete the node cache and this fails.
|
||||
const early = mkdtempSync(join(tmpdir(), 'orca-shim-path-early-'))
|
||||
const late = mkdtempSync(join(tmpdir(), 'orca-shim-path-late-'))
|
||||
const shimDir = mkdtempSync(join(tmpdir(), 'orca-shim-path-'))
|
||||
try {
|
||||
writeFileSync(join(late, 'node.exe'), '')
|
||||
writeFileSync(join(shimDir, 'cli.js'), '')
|
||||
const shim = join(shimDir, 'walk.cmd')
|
||||
writeFileSync(shim, npmProgNodeShim('cli.js'))
|
||||
const pathEnv = { ...env, Path: undefined, PATH: `${early};${late}` }
|
||||
|
||||
expect(resolveWindowsCmdShim(shim, pathEnv)?.program).toBe(join(late, 'node.exe'))
|
||||
|
||||
writeFileSync(join(early, 'node.exe'), '')
|
||||
expect(resolveWindowsCmdShim(shim, pathEnv)?.program).toBe(join(late, 'node.exe'))
|
||||
|
||||
// A PATH edit must miss: caching the walk must not outlive its input.
|
||||
expect(resolveWindowsCmdShim(shim, { ...pathEnv, PATH: `${early};${late};` })?.program).toBe(
|
||||
join(early, 'node.exe')
|
||||
)
|
||||
} finally {
|
||||
removeTreeSync(early)
|
||||
removeTreeSync(late)
|
||||
removeTreeSync(shimDir)
|
||||
}
|
||||
})
|
||||
|
||||
it('gives up when cmd would resolve `node` to a non-.exe PATHEXT spelling', () => {
|
||||
// cmd stops at the first PATH directory holding ANY PATHEXT spelling, and
|
||||
// `.COM` outranks `.EXE`, so `first\node.com` is what the shim actually
|
||||
// runs. Scanning past it to `second\node.exe` would silently start a
|
||||
// different binary -- so resolution gives up and cmd.exe keeps the job.
|
||||
// Delete the PATHEXT loop and this returns the .exe instead of null.
|
||||
const first = mkdtempSync(join(tmpdir(), 'orca-shim-ext-first-'))
|
||||
const second = mkdtempSync(join(tmpdir(), 'orca-shim-ext-second-'))
|
||||
const shimDir = mkdtempSync(join(tmpdir(), 'orca-shim-ext-'))
|
||||
try {
|
||||
writeFileSync(join(first, 'node.com'), '')
|
||||
const nodeExe = join(second, 'node.exe')
|
||||
writeFileSync(nodeExe, '')
|
||||
writeFileSync(join(shimDir, 'cli.js'), '')
|
||||
const shim = join(shimDir, 'pathext.cmd')
|
||||
writeFileSync(shim, npmProgNodeShim('cli.js'))
|
||||
const pathEnv = { ...env, Path: undefined, PATH: `${first};${second}` }
|
||||
|
||||
expect(resolveWindowsCmdShim(shim, { ...pathEnv, PATHEXT: undefined })).toBeNull()
|
||||
|
||||
// PATHEXT is honoured, not assumed: with `.COM` absent from it, cmd never
|
||||
// considers the `node.com` and the `.exe` is the right answer again. This
|
||||
// also pins PATHEXT into the cache key -- the two calls differ only there.
|
||||
expect(resolveWindowsCmdShim(shim, { ...pathEnv, PATHEXT: '.EXE;.BAT' })?.program).toBe(
|
||||
nodeExe
|
||||
)
|
||||
} finally {
|
||||
removeTreeSync(first)
|
||||
removeTreeSync(second)
|
||||
removeTreeSync(shimDir)
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to cmd.exe when the cached interpreter has been uninstalled', () => {
|
||||
// The mirror of the test above, and the direction that breaks: a cached
|
||||
// node.exe that is later removed must not still be handed to `resolveSpawn`,
|
||||
// which would fail the spawn with ENOENT where an uncached process falls
|
||||
// back to cmd.exe and succeeds. Delete the `statFile` on the cache hit and
|
||||
// this returns the deleted path instead of null.
|
||||
const nodeDir = mkdtempSync(join(tmpdir(), 'orca-shim-gone-node-'))
|
||||
const shimDir = mkdtempSync(join(tmpdir(), 'orca-shim-gone-'))
|
||||
try {
|
||||
const nodeExe = join(nodeDir, 'node.exe')
|
||||
writeFileSync(nodeExe, '')
|
||||
writeFileSync(join(shimDir, 'cli.js'), '')
|
||||
const shim = join(shimDir, 'gone.cmd')
|
||||
writeFileSync(shim, npmProgNodeShim('cli.js'))
|
||||
const pathEnv = { ...env, Path: undefined, PATH: nodeDir }
|
||||
|
||||
expect(resolveWindowsCmdShim(shim, pathEnv)?.program).toBe(nodeExe)
|
||||
|
||||
rmSync(nodeExe)
|
||||
expect(resolveWindowsCmdShim(shim, pathEnv)).toBeNull()
|
||||
} finally {
|
||||
removeTreeSync(nodeDir)
|
||||
removeTreeSync(shimDir)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps cmd.exe out of the spawn for a recognised shim', () => {
|
||||
const resolved = resolveSpawn(
|
||||
{
|
||||
program: write('spawned.cmd', npmProgNodeShim('cli.js')),
|
||||
args: ['a b', 'c"d'],
|
||||
env
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
expect(resolved.file.toLowerCase()).not.toContain('cmd.exe')
|
||||
expect(resolved.args).toEqual([join(dir, 'cli.js'), 'a b', 'c"d'])
|
||||
// Node's own quoting is CommandLineToArgvW-correct; the verbatim line is
|
||||
// only needed for the cmd hop we just removed.
|
||||
expect(resolved.options.windowsVerbatimArguments).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears a caller-set windowsVerbatimArguments on the resolved path', () => {
|
||||
// The flag means "I built the whole command line, hand it through". There
|
||||
// is no such line here, so honouring it would make Node join
|
||||
// `[script, ...args]` unquoted and shred every argument with a space.
|
||||
const resolved = resolveSpawn(
|
||||
{
|
||||
program: write('verbatim.cmd', npmProgNodeShim('cli.js')),
|
||||
args: ['a b'],
|
||||
env,
|
||||
windowsVerbatimArguments: true
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
expect(resolved.options.windowsVerbatimArguments).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still routes an unrecognised .cmd through cmd.exe', () => {
|
||||
const resolved = resolveSpawn(
|
||||
{
|
||||
program: write('plain.cmd', REAL_PN_CMD),
|
||||
args: ['x'],
|
||||
env: { ComSpec: 'C:\\W\\cmd.exe' }
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
expect(resolved.file).toBe('C:\\W\\cmd.exe')
|
||||
expect(resolved.args[0]).toContain('/d /v:off /s /c')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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. */
|
||||
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. */
|
||||
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`.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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`). */
|
||||
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`. */
|
||||
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.
|
||||
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. */
|
||||
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/, '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.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.
|
||||
*/
|
||||
export function parseWindowsCmdShim(contents: string): ParsedWindowsCmdShim | null {
|
||||
const canonical = canonicalize(contents)
|
||||
|
||||
const prog = NPM_PROG_NODE_SHIM.exec(canonical)?.groups
|
||||
if (prog?.script) {
|
||||
return isPlainRelativePath(prog.script) ? { kind: 'node', script: prog.script } : null
|
||||
}
|
||||
|
||||
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.
|
||||
if (branched.script !== branched.scriptElse || !isPlainRelativePath(branched.script)) {
|
||||
return null
|
||||
}
|
||||
const nodePath = branched.nodePath
|
||||
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.
|
||||
if (nodePath.includes('%') || branched.nodePathElse !== `${nodePath};%NODE_PATH%`) {
|
||||
return null
|
||||
}
|
||||
return { kind: 'node', script: branched.script, nodePathPrefix: nodePath }
|
||||
}
|
||||
|
||||
for (const pattern of [NPM_DIRECT_SHIM, PNPM_DIRECT_SHIM]) {
|
||||
const target = pattern.exec(canonical)?.groups?.target
|
||||
if (target) {
|
||||
return isPlainRelativePath(target) ? { kind: 'direct', target } : null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type ParseCacheEntry = {
|
||||
mtimeMs: number
|
||||
size: number
|
||||
parsed: ParsedWindowsCmdShim | null
|
||||
}
|
||||
|
||||
/** Shim bodies do not change between spawns, but an upgrade rewrites them —
|
||||
* hence the mtime/size half of the key. */
|
||||
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.
|
||||
*/
|
||||
const nodeCache = new Map<string, string | null>()
|
||||
const NODE_CACHE_LIMIT = 256
|
||||
|
||||
function statFile(path: string): Stats | null {
|
||||
try {
|
||||
const stats = statSync(path)
|
||||
return stats.isFile() ? stats : null
|
||||
} catch {
|
||||
return 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.
|
||||
const stats = statFile(program)
|
||||
if (!stats || stats.size > MAX_SHIM_BYTES) {
|
||||
return null
|
||||
}
|
||||
const cached = parseCache.get(program)
|
||||
if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) {
|
||||
return cached.parsed
|
||||
}
|
||||
let contents: string
|
||||
try {
|
||||
contents = readFileSync(program, 'utf8')
|
||||
} catch {
|
||||
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()
|
||||
}
|
||||
parseCache.set(program, { mtimeMs: stats.mtimeMs, size: stats.size, parsed })
|
||||
return parsed
|
||||
}
|
||||
|
||||
/** Win32 resolves environment names case-insensitively; a JS object does not. */
|
||||
function firstEnvKey(env: NodeJS.ProcessEnv, name: string): string | undefined {
|
||||
const lower = name.toLowerCase()
|
||||
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. */
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
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()
|
||||
}
|
||||
nodeCache.set(key, resolved)
|
||||
return resolved
|
||||
}
|
||||
|
||||
function probeShimNode(directory: string, pathValue: string, pathExtValue: string): string | null {
|
||||
const sibling = win32.join(directory, 'node.exe')
|
||||
if (statFile(sibling)) {
|
||||
return sibling
|
||||
}
|
||||
const extensions = pathExtValue
|
||||
.split(';')
|
||||
.map((extension) => extension.trim().toLowerCase())
|
||||
.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.
|
||||
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.
|
||||
for (const extension of extensions) {
|
||||
const candidate = win32.join(trimmed, `node${extension}`)
|
||||
if (!statFile(candidate)) {
|
||||
continue
|
||||
}
|
||||
return extension === '.exe' ? candidate : null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function withNodePath(env: NodeJS.ProcessEnv, prefix: string): NodeJS.ProcessEnv {
|
||||
const key = firstEnvKey(env, 'NODE_PATH') ?? 'NODE_PATH'
|
||||
const existing = env[key]
|
||||
// `IF NOT DEFINED` is false for an empty value too — cmd has no empty variables.
|
||||
return { ...env, [key]: existing ? `${prefix};${existing}` : prefix }
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
export function resolveWindowsCmdShim(
|
||||
program: string,
|
||||
env: NodeJS.ProcessEnv
|
||||
): WindowsCmdShimResolution | null {
|
||||
const disableKey = firstEnvKey(env, DISABLE_FLAG)
|
||||
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.
|
||||
if (!win32.isAbsolute(program)) {
|
||||
return null
|
||||
}
|
||||
const parsed = readParsedShim(program)
|
||||
if (!parsed) {
|
||||
return null
|
||||
}
|
||||
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()
|
||||
if (!DIRECT_TARGET_EXTENSIONS.some((extension) => lower.endsWith(extension))) {
|
||||
return null
|
||||
}
|
||||
return statFile(target) ? { program: target, prefixArgs: [] } : null
|
||||
}
|
||||
|
||||
const script = win32.resolve(directory, parsed.script)
|
||||
if (!statFile(script)) {
|
||||
return null
|
||||
}
|
||||
const node = resolveShimNode(directory, env)
|
||||
if (!node) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
program: node,
|
||||
prefixArgs: [script],
|
||||
...(parsed.nodePathPrefix ? { env: withNodePath(env, parsed.nodePathPrefix) } : {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { removeTreeSync } from '../windows-transient-lock-removal'
|
||||
import { runProcess } from './run-process'
|
||||
import { WINDOWS_ARGUMENT_CORPUS } from './__fixtures__/windows-argument-corpus'
|
||||
import { npmProgNodeShim } from './__fixtures__/windows-cmd-shim-bodies'
|
||||
|
||||
/**
|
||||
* The resolved path has to deliver exactly what the cmd.exe path delivers, for
|
||||
* the same real shim on a real Windows box. Anything less and removing cmd.exe
|
||||
* has traded an EDR alert for a silent argument bug.
|
||||
*
|
||||
* Runs only on win32; skipped elsewhere.
|
||||
*/
|
||||
const describeOnWindows = process.platform === 'win32' ? describe : describe.skip
|
||||
|
||||
describeOnWindows('resolved .cmd shim spawn', () => {
|
||||
let dir: string
|
||||
let shim: string
|
||||
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'orca-shim-spawn-'))
|
||||
shim = join(dir, 'echoargs.cmd')
|
||||
writeFileSync(shim, npmProgNodeShim('echoargs.js'))
|
||||
writeFileSync(
|
||||
join(dir, 'echoargs.js'),
|
||||
'process.stdout.write(process.argv.slice(2).map((a) => `ARG<${a}>`).join("\\u0000"))\n'
|
||||
)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
removeTreeSync(dir)
|
||||
})
|
||||
|
||||
function decode(stdout: string): string[] {
|
||||
return stdout.split('\u0000').map((entry) => entry.replace(/^ARG<([\s\S]*)>$/, '$1'))
|
||||
}
|
||||
|
||||
it('delivers the whole adversarial corpus through the resolved shim', async () => {
|
||||
const values = WINDOWS_ARGUMENT_CORPUS.map((entry) => entry.value)
|
||||
const result = await runProcess({
|
||||
program: shim,
|
||||
args: values,
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
expect(result.code).toBe(0)
|
||||
expect(decode(result.stdout)).toEqual(values)
|
||||
})
|
||||
|
||||
it('delivers a multi-line agent prompt that cmd.exe could not carry at all', async () => {
|
||||
// cmd ends the command at a raw CR/LF whatever the quote state, so the
|
||||
// fallback path has to reject this input. Resolving the shim is what makes
|
||||
// it expressible.
|
||||
const prompt = 'Fix "src/a b.ts"\n- run tests\r\n- report 100% & stop'
|
||||
const result = await runProcess({
|
||||
program: shim,
|
||||
args: [prompt],
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
expect(result.code).toBe(0)
|
||||
expect(decode(result.stdout)).toEqual([prompt])
|
||||
})
|
||||
|
||||
it('reports the script exit code without a cmd.exe hop in between', async () => {
|
||||
const failing = join(dir, 'fail.cmd')
|
||||
writeFileSync(failing, npmProgNodeShim('fail.js'))
|
||||
writeFileSync(join(dir, 'fail.js'), 'process.exit(7)\n')
|
||||
const result = await runProcess({ program: failing, timeoutMs: 30_000 })
|
||||
expect(result.code).toBe(7)
|
||||
})
|
||||
})
|
||||
@@ -101,7 +101,9 @@ export function buildWindowsCmdShimCommandLine(program: string, args: readonly s
|
||||
// does not survive a line break. Encoding one anyway truncates the argument
|
||||
// and can leave the remainder to be interpreted as a further command. Agent
|
||||
// prompts are the motivating input here and can contain newlines, so this
|
||||
// has to fail loudly rather than silently mangle.
|
||||
// has to fail loudly rather than silently mangle. Recognised npm/pnpm shims
|
||||
// no longer reach this line at all — windows-cmd-shim-resolution.ts spawns
|
||||
// their target directly, where a newline is just another character.
|
||||
for (const value of [program, ...args]) {
|
||||
if (/[\r\n]/.test(value)) {
|
||||
throw new Error('cmd.exe cannot receive an argument containing a line break')
|
||||
|
||||
@@ -45,8 +45,9 @@ const SPAWN_CALL =
|
||||
/\b(?:spawn|spawnSync|spawnDetached|execFile|execFileSync|execFileAsync|execFileCb|exec|execSync|execAsync)\s*\(/g
|
||||
const SOURCE_ROOT = resolve(__dirname, '../..')
|
||||
/**
|
||||
* `run-process.ts` is the chokepoint: it sets windowsHide in `resolveSpawn`,
|
||||
* not at the call, so scanning it flags its own implementation.
|
||||
* `run-process.ts` is the chokepoint: the flag comes from `resolveSpawn` (now
|
||||
* in `spawn-resolution.ts`), not from the call, so scanning it flags its own
|
||||
* implementation.
|
||||
*
|
||||
* `fork` is deliberately absent from SPAWN_CALL. Node forwards the option to
|
||||
* spawn at runtime, but `ForkOptions` does not declare it, so the two live
|
||||
|
||||
Reference in New Issue
Block a user