Files
orca/src/shared/powershell-native-argument.windows.test.ts
T
OrcaWinandOrca Worker 5df4fef917 fix(windows): codex and claude get a prompt's double quotes intact in PowerShell (#30424)
* fix(windows): codex and claude get a prompt's double quotes in Windows PowerShell

Windows PowerShell 5.1 (and pwsh before 7.3) does not escape an embedded
`"` when it builds a native program's command line, so every quote in a
launch prompt was dropped, and quotes could split the prompt into extra
arguments. Orca's PowerShell codex and claude wrappers now escape their
arguments at runtime for the PowerShell that is actually running, through
one shared __OrcaNativeArgs function. pwsh 7.3+ already escapes, so it
gets the text untouched.

* fix(windows): escape agent args for .cmd shims under pwsh 7.3+ too

pwsh 7.3+ defaults to the Windows argument mode, which still passes
.cmd/.bat targets the Legacy way, so a codex or claude .cmd shim kept
dropping quotes there. The function now takes the program it escapes
for and names the modes that escape by themselves, and anchors the 5.1
trailing-backslash fix at the true end of the text (`$` also matched
before a final newline). Tests now run the real codex wrapper with a
quoted prompt, cover pwsh's Legacy mode and the newline case, and pin
the claude wrapper's call.

* fix(windows): strip comment lines from the PowerShell bootstrap payload

The shared escaping function pushed the encoded bootstrap to 27,324 of
the 28,000-character budget, leaving almost no room for a startup
command to ride in -EncodedCommand, so short commands fell back to
stdin (the path #7236 broke). Comment lines document the source but
only spend that budget in the payload; dropping them frees 3,732
encoded characters, more headroom than main had.

* fix(windows): quote agent args with "" where cmd.exe or Windows PowerShell reads them

Escaping embedded quotes as \" let pwsh 7.3+'s .cmd quoting expose a
prompt's `&` to cmd.exe, which then ran the rest as a command. Where
cmd.exe reads the line, or Windows PowerShell counts quotes naively,
the function now quotes the argument itself and writes each quote as
"", as process-host's windows-command-line.ts already does. That also
delivers text 5.1 used to strip, so its special cases go away.

The codex and claude wrappers now come as one fragment with the
function they call, the bootstrap test pins the startup-command budget
instead of restating the strip, and the cwd-restore comment leaves the
payload too.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-10-18 17:29:54 -07:00

158 lines
5.9 KiB
TypeScript

import { spawnSync } from 'node:child_process'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { runProcess } from '@orca/process-host'
import { getPowerShellAgentWrappers } from './powershell-agent-wrappers'
import {
getPowerShellEscapeNativeArgsFunction,
quotePowerShellLiteral
} from './powershell-native-argument'
/** Runs `build(native)` in `powershell`, where `native` invokes a program that records its argv. */
async function captureNativeArgv(
root: string,
powershell: string,
build: (native: string) => string
): Promise<string[]> {
const capturePath = join(root, 'argv.json')
const scriptPath = join(root, 'capture.js')
await writeFile(
scriptPath,
`require('node:fs').writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify(process.argv.slice(2)))`
)
const native = `& ${quotePowerShellLiteral(process.execPath)} ${quotePowerShellLiteral(scriptPath)}`
const result = await runProcess({
program: powershell,
args: ['-NoProfile', '-NonInteractive', '-Command', build(native)],
cwd: root
})
expect(result.code, JSON.stringify(result)).toBe(0)
const argv: string[] = JSON.parse(await readFile(capturePath, 'utf8'))
return argv
}
// Why native: argv marshalling, not the generated text, is what a launched agent actually receives.
it.skipIf(process.platform !== 'win32')(
'passes a multi-line value to a native program exactly as the single-quoted form did',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ps-multiline-'))
const capture = (quoted: string): Promise<string[]> =>
captureNativeArgv(root, 'powershell.exe', (native) => `${native} ${quoted}`)
try {
const plain = 'line one\r\nline two $HOME `tick\nO\u2019Brien \u201Cdouble\u201D end'
expect(await capture(quotePowerShellLiteral(plain))).toEqual([plain])
// Why compare with the old raw single-quoted form: 5.1 drops embedded `"` from native argv
// either way, and the one-line form must not change what reaches the program.
const withQuotes = 'say "hi"\nbye'
const legacy = `'${withQuotes.replace(/'/g, "''")}'`
expect(await capture(quotePowerShellLiteral(withQuotes))).toEqual(await capture(legacy))
} finally {
await rm(root, { recursive: true, force: true })
}
}
)
const isWindows = process.platform === 'win32'
const pwshAvailable =
isWindows &&
spawnSync('pwsh.exe', ['-NoProfile', '-Command', 'exit 0'], { windowsHide: true }).status === 0
const POWERSHELLS = [
{ name: 'Windows PowerShell', powershell: 'powershell.exe', prelude: '', available: isWindows },
{ name: 'pwsh', powershell: 'pwsh.exe', prelude: '', available: pwshAvailable },
{
name: 'pwsh with Legacy passing',
powershell: 'pwsh.exe',
prelude: "$PSNativeCommandArgumentPassing = 'Legacy'",
available: pwshAvailable
}
]
describe.each(POWERSHELLS)(
'__OrcaEscapeNativeArgs under $name',
({ powershell, prelude, available }) => {
it.skipIf(!available)('delivers embedded quotes and backslashes exactly', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ps-native-args-'))
try {
for (const value of [
'say "hi" to "bob"',
'line1 "a"\nline2 "b c"',
'path C:\\dir\\" end "q"',
'a "" b',
'a"b',
'a"b c"d',
'"fix the login bug"',
'look in C:\\dir\\',
'look in C:\\dir\\\n',
'C:\\no-space\\',
'C:\\no-space\\\n',
'Fix " --dangerously-bypass-approvals-and-sandbox " thanks'
]) {
// Why assign then splat: the agent wrappers hand their $args to the program this way.
const argv = await captureNativeArgv(
root,
powershell,
(native) =>
`${prelude}\n${getPowerShellEscapeNativeArgsFunction()}\n` +
`function agent { $a = @(__OrcaEscapeNativeArgs 'node.exe' $args); ${native} @a }\n` +
`agent ${quotePowerShellLiteral(value)}`
)
expect(argv).toEqual([value])
}
} finally {
await rm(root, { recursive: true, force: true })
}
})
}
)
describe.each(POWERSHELLS.slice(0, 2))(
'agent wrappers under $name',
({ powershell, available }) => {
it.skipIf(!available)(
'hand a quoted prompt to a .cmd shim intact, without cmd running any of it',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ps-agent-wrappers-'))
try {
await writeFile(
join(root, 'capture.js'),
'console.log(JSON.stringify([require("node:path").parse(process.argv[2]).name, ...process.argv.slice(3)]))'
)
for (const agent of ['codex', 'claude']) {
await writeFile(join(root, `${agent}.cmd`), `@node "%~dp0capture.js" "%~f0" %*\r\n`)
}
// Why this shape: escaping it as `\"` makes pwsh's .cmd quoting expose `&` to cmd.
const prompt = 'a"b & echo INJECTED'
const result = await runProcess({
program: powershell,
args: [
'-NoProfile',
'-NonInteractive',
'-Command',
`${getPowerShellAgentWrappers()}\ncodex ${quotePowerShellLiteral(prompt)}\nclaude ${quotePowerShellLiteral(prompt)}`
],
cwd: root,
env: {
...process.env,
PATH: `${root};${process.env.PATH ?? ''}`,
ORCA_CODEX_ISOLATE: '0',
ORCA_CLAUDE_PROFILE_POINTER: join(root, 'no-pointer')
}
})
expect(result.code, JSON.stringify(result)).toBe(0)
expect(result.stdout.trim().split(/\r?\n/)).toEqual([
JSON.stringify(['codex', prompt]),
JSON.stringify(['claude', prompt])
])
} finally {
await rm(root, { recursive: true, force: true })
}
}
)
}
)