diff --git a/src/main/powershell-osc133-bootstrap.test.ts b/src/main/powershell-osc133-bootstrap.test.ts index 6cf8854995d..383a970737c 100644 --- a/src/main/powershell-osc133-bootstrap.test.ts +++ b/src/main/powershell-osc133-bootstrap.test.ts @@ -8,6 +8,20 @@ import { resolveWindowsShellLaunchArgs } from './providers/windows-shell-args' import { STARTUP_COMMAND_FEATURES } from './shell-startup-launch-intent-fixtures' describe('PowerShell OSC 133 bootstrap', () => { + // Why: the bootstrap shares the -EncodedCommand budget with the startup command riding along. + it('leaves room for a long startup command to ride in the shell args', () => { + const cwd = `C:\\Users\\alice\\${'nested-project-folder\\'.repeat(5)}repo` + const result = resolveWindowsShellLaunchArgs( + 'pwsh.exe', + cwd, + cwd, + undefined, + `orca ${'x'.repeat(1000)}` + ) + + expect(result.startupCommandDeliveredInShellArgs).toBe(true) + }) + it('wraps prompt/readline without bypassing profiles or execution policy', () => { const script = getPowerShellOsc133Bootstrap() @@ -50,7 +64,7 @@ describe('PowerShell OSC 133 bootstrap', () => { // Why pinned: the MDE review (see powershell-osc133-bootstrap.ts) declined a // switch to -Command. Any future delivery shape must still hand PowerShell this - // payload byte for byte -- comments, quotes, `$` and newlines included. + // payload byte for byte -- quotes, `$` and newlines included. describe.each([ [ 'daemon shell-ready', diff --git a/src/main/powershell-osc133-bootstrap.ts b/src/main/powershell-osc133-bootstrap.ts index 99f2a10a545..51d65a087a3 100644 --- a/src/main/powershell-osc133-bootstrap.ts +++ b/src/main/powershell-osc133-bootstrap.ts @@ -1,7 +1,6 @@ import { getPowerShellOmpShellWrapper } from './pty/omp-shell-wrapper' import { MANAGED_DATA_ACCOUNT_POWERSHELL_RESTORE } from '../shared/managed-data-account-shell' -import { getPowerShellCodexShellLaunchPreflight } from '../shared/codex-shell-function' -import { getPowerShellClaudeShellFunction } from '../shared/claude-shell-function' +import { getPowerShellAgentWrappers } from '../shared/powershell-agent-wrappers' export { encodePowerShellCommand } from '../shared/powershell-command-encoding' /** @@ -59,7 +58,7 @@ if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage" -and } catch { Write-Error $_ -ErrorAction Continue } ${getPowerShellOmpShellWrapper()} -${getPowerShellCodexShellLaunchPreflight() + getPowerShellClaudeShellFunction()} +${getPowerShellAgentWrappers()} $Global:__OrcaOsc133State = @{ OriginalPrompt = $function:prompt @@ -102,8 +101,15 @@ ${getPowerShellCodexShellLaunchPreflight() + getPowerShellClaudeShellFunction()} } ` +// Why strip comment lines: they document the source, but in the payload they only spend the +// command-line budget that lets a startup command ride along (windows-shell-args.ts). +const POWERSHELL_OSC133_BOOTSTRAP_PAYLOAD = POWERSHELL_OSC133_BOOTSTRAP.replace( + /^[ \t]*#.*\n/gm, + '' +) + export function getPowerShellOsc133Bootstrap(): string { - return POWERSHELL_OSC133_BOOTSTRAP + return POWERSHELL_OSC133_BOOTSTRAP_PAYLOAD } export function isPowerShellExecutableName(shellName: string): boolean { diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index b905a3fbb30..0444294a1c5 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -100,9 +100,9 @@ function getCmdShellArgStartupCommand(command?: string): string | null { * large payloads return the bootstrap alone so stdin delivery remains available. */ function getPowerShellRestoreCwdCommand(cwd: string): string { + // Why: profiles can change location, so restore the PTY cwd after profile loading. return [ '', - '# Profiles can change location; restore the PTY cwd after profile loading.', `try { Set-Location -LiteralPath ${quoteStartupArg(cwd, 'powershell')} -ErrorAction Stop } catch { Write-Warning "Failed to restore working directory: $_" }` ].join('\n') } diff --git a/src/main/pty/codex-shell-launch-preflight.test.ts b/src/main/pty/codex-shell-launch-preflight.test.ts index 2586b2a7c98..77ba5b4fa73 100644 --- a/src/main/pty/codex-shell-launch-preflight.test.ts +++ b/src/main/pty/codex-shell-launch-preflight.test.ts @@ -20,6 +20,7 @@ import { getPowerShellCodexShellLaunchPreflight } from '../../shared/codex-shell-function' import { fishRequirementViolation, resolveFishBinary } from '../../shared/fish-binary-requirement' +import { getPowerShellAgentWrappers } from '../../shared/powershell-agent-wrappers' const roots: string[] = [] const zshAvailable = existsSync('/bin/zsh') @@ -392,7 +393,7 @@ describe('PowerShell Codex shell launch preflight', () => { [ '$ErrorActionPreference = "Stop"', '$PSNativeCommandUseErrorActionPreference = $true', - getPowerShellCodexShellLaunchPreflight(), + getPowerShellAgentWrappers(), 'codex' ].join('\n') ], @@ -563,7 +564,7 @@ describe.skipIf(process.platform === 'win32')('Codex preflight paths containing const result = spawnSync( 'pwsh', - ['-NoLogo', '-NoProfile', '-Command', `${getPowerShellCodexShellLaunchPreflight()}\ncodex`], + ['-NoLogo', '-NoProfile', '-Command', `${getPowerShellAgentWrappers()}\ncodex`], { encoding: 'utf-8', env: { diff --git a/src/main/pty/codex-shell-no-daemon.test.ts b/src/main/pty/codex-shell-no-daemon.test.ts index f2d5ecbfeae..281a51d09bc 100644 --- a/src/main/pty/codex-shell-no-daemon.test.ts +++ b/src/main/pty/codex-shell-no-daemon.test.ts @@ -13,10 +13,10 @@ import { spawnSync } from 'node:child_process' import { afterEach, describe, expect, it } from 'vitest' import { getFishCodexShellLaunchPreflight, - getPosixCodexShellLaunchPreflight, - getPowerShellCodexShellLaunchPreflight + getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function' import { resolveFishBinary } from '../../shared/fish-binary-requirement' +import { getPowerShellAgentWrappers } from '../../shared/powershell-agent-wrappers' const isWindows = process.platform === 'win32' const fishLookup = resolveFishBinary() @@ -31,6 +31,8 @@ const HELP_WITHOUT_FLAG = 'Usage: codex [OPTIONS] [PROMPT]\n --no-alt-scree const ADDED: string[][] = [ [], ['fix the bug'], + // Why: Windows PowerShell and pwsh's .cmd passing drop embedded quotes unless the wrapper escapes them. + ['say "hi" to "bob"'], ['exec the plan'], ['-m', 'gpt-5', '--yolo', 'x'], ['resume'], @@ -160,7 +162,7 @@ function run( shell === 'fish' ? getFishCodexShellLaunchPreflight() : isPowerShell(shell) - ? getPowerShellCodexShellLaunchPreflight() + ? getPowerShellAgentWrappers() : getPosixCodexShellLaunchPreflight() // Why the guard: rows include `login`, which a real codex would run against the host's account. const guard = isPowerShell(shell) diff --git a/src/shared/claude-shell-function.test.ts b/src/shared/claude-shell-function.test.ts index 31d5aa06e47..aae23c87731 100644 --- a/src/shared/claude-shell-function.test.ts +++ b/src/shared/claude-shell-function.test.ts @@ -209,7 +209,7 @@ it("leaves the shell's Anthropic auth alone in the PowerShell function", () => { it('restores PowerShell process env without creating empty variables', () => { const script = getPowerShellClaudeShellFunction() - expect(script.startsWith('\n$orcaClaudeCommand = Get-Command claude')).toBe(true) + expect(script.startsWith('$orcaClaudeCommand = Get-Command claude')).toBe(true) expect(script).toContain('finally {') // .NET 9+ turns a $null/'' SetEnvironmentVariable into an empty variable, not a removal. expect(script).not.toMatch(/SetEnvironmentVariable\([^)]*,\s*(\$null|''|"")\s*,/) @@ -229,6 +229,12 @@ it('prints no Orca text from the PowerShell function and creates a missing accou expect(script).toContain('-not [IO.Path]::IsPathRooted($orcaClaudeHome)') }) +it('escapes its arguments for the resolved PowerShell binary before splatting them', () => { + expect(getPowerShellClaudeShellFunction()).toContain( + '$claudeArgs = @(__OrcaEscapeNativeArgs $binary.Source $args)' + ) +}) + it('trims the PowerShell pointer read as POSIX command substitution does', () => { expect(getPowerShellClaudeShellFunction()).toContain( '[IO.File]::ReadAllText($env:ORCA_CLAUDE_PROFILE_POINTER).TrimEnd()' diff --git a/src/shared/claude-shell-function.ts b/src/shared/claude-shell-function.ts index 9f0c5e02628..6389c3399a6 100644 --- a/src/shared/claude-shell-function.ts +++ b/src/shared/claude-shell-function.ts @@ -71,10 +71,8 @@ set -e __orca_claude_type ` } -/** Leading newline: the codex fragment it follows ends without one. */ export function getPowerShellClaudeShellFunction(): string { - return ` -$orcaClaudeCommand = Get-Command claude -ErrorAction SilentlyContinue | Select-Object -First 1 + return `$orcaClaudeCommand = Get-Command claude -ErrorAction SilentlyContinue | Select-Object -First 1 if ($env:ORCA_CLAUDE_PROFILE_POINTER -and $orcaClaudeCommand -and $orcaClaudeCommand.CommandType -in @("Application", "ExternalScript")) { function Global:claude { @@ -104,7 +102,8 @@ function Global:claude { $env:ORCA_CLAUDE_INJECTED_CONFIG_DIR = $orcaClaudeHome } $binary = Get-Command claude -CommandType Application,ExternalScript -ErrorAction Stop | Select-Object -First 1 - if ($MyInvocation.ExpectingInput) { $input | & $binary.Source @args } else { & $binary.Source @args } + $claudeArgs = @(__OrcaEscapeNativeArgs $binary.Source $args) + if ($MyInvocation.ExpectingInput) { $input | & $binary.Source @claudeArgs } else { & $binary.Source @claudeArgs } $global:LASTEXITCODE = $LASTEXITCODE } catch { $global:LASTEXITCODE = 1; Write-Error $_ -ErrorAction Continue } finally { diff --git a/src/shared/codex-shell-function.ts b/src/shared/codex-shell-function.ts index c886917a007..1e56da76d4f 100644 --- a/src/shared/codex-shell-function.ts +++ b/src/shared/codex-shell-function.ts @@ -81,11 +81,12 @@ if ($orcaCodexCommand -and } catch { } } + $orcaCodexArgs = @(__OrcaEscapeNativeArgs $orcaCodexExecutable.Source $args) # Why: a native command inside a function never sees the function's pipeline input on its own. if ($MyInvocation.ExpectingInput) { - $input | & $orcaCodexExecutable.Source @orcaCodexFlags @args + $input | & $orcaCodexExecutable.Source @orcaCodexFlags @orcaCodexArgs } else { - & $orcaCodexExecutable.Source @orcaCodexFlags @args + & $orcaCodexExecutable.Source @orcaCodexFlags @orcaCodexArgs } $global:LASTEXITCODE = $LASTEXITCODE } diff --git a/src/shared/powershell-agent-wrappers.ts b/src/shared/powershell-agent-wrappers.ts new file mode 100644 index 00000000000..67cf890f4ea --- /dev/null +++ b/src/shared/powershell-agent-wrappers.ts @@ -0,0 +1,12 @@ +import { getPowerShellClaudeShellFunction } from './claude-shell-function' +import { getPowerShellCodexShellLaunchPreflight } from './codex-shell-function' +import { getPowerShellEscapeNativeArgsFunction } from './powershell-native-argument' + +/** Orca's PowerShell `codex` and `claude` wrappers, after the argument escaping both call. */ +export function getPowerShellAgentWrappers(): string { + return [ + getPowerShellEscapeNativeArgsFunction(), + getPowerShellCodexShellLaunchPreflight(), + getPowerShellClaudeShellFunction() + ].join('\n') +} diff --git a/src/shared/powershell-native-argument.ts b/src/shared/powershell-native-argument.ts index 23e517505f0..db8d1f8cffa 100644 --- a/src/shared/powershell-native-argument.ts +++ b/src/shared/powershell-native-argument.ts @@ -19,6 +19,33 @@ function quotePowerShellMultilineLiteral(value: string): string { return `"${escaped}"` } +/** + * PowerShell function that escapes arguments for `$Program`, for Orca's agent wrappers to splat. + * + * Why at runtime: the escaping depends on the PowerShell reading the line. Legacy argv passing + * (Windows PowerShell 5.1, pwsh before 7.3, and 7.3's `Windows` mode for `.cmd`/`.bat`) leaves + * embedded `"` unescaped, so the program drops them; otherwise PowerShell escapes them itself and + * must get the text untouched. + * + * Where cmd.exe reads the line, or Windows PowerShell decides quoting by counting `"` naively, the + * argument is quoted here with `""` for each quote, as process-host's windows-command-line.ts does: + * a `\"` would flip cmd's quote parity and turn a later `&` into an operator. + */ +export function getPowerShellEscapeNativeArgsFunction(): string { + return `function Global:__OrcaEscapeNativeArgs([string]$Program, [object[]]$Arguments) { + Set-StrictMode -Off + $mode = $PSNativeCommandArgumentPassing + $cmd = $Program -match '\\.(bat|cmd)$' + if ($mode -eq 'Standard' -or ($mode -eq 'Windows' -and -not $cmd)) { return $Arguments } + $selfQuote = $cmd -or $PSVersionTable.PSVersion.Major -lt 6 + foreach ($arg in $Arguments) { + if ($arg -isnot [string] -or $arg -notmatch '["\\\\]') { $arg; continue } + if ($selfQuote) { '"' + (($arg -replace '(\\\\*)"', '$1$1""') -replace '(\\\\+)\\z', '$1$1') + '"' } + else { $arg -replace '(\\\\*)"', '$1$1\\"' } + } +}` +} + export function quotePowerShellNativeArgument(value: string): string { // Why: Windows PowerShell 5.1 drops unescaped embedded quotes when it // constructs argv for native executables such as wsl.exe. diff --git a/src/shared/powershell-native-argument.windows.test.ts b/src/shared/powershell-native-argument.windows.test.ts index 7c8acead625..959fe8c77bc 100644 --- a/src/shared/powershell-native-argument.windows.test.ts +++ b/src/shared/powershell-native-argument.windows.test.ts @@ -1,25 +1,31 @@ +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 { expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { runProcess } from '@orca/process-host' -import { quotePowerShellLiteral } from './powershell-native-argument' +import { getPowerShellAgentWrappers } from './powershell-agent-wrappers' +import { + getPowerShellEscapeNativeArgsFunction, + quotePowerShellLiteral +} from './powershell-native-argument' -async function captureNativeArgv(root: string, quotedArg: string): Promise { +/** 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 { 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.exe', - args: [ - '-NoProfile', - '-NonInteractive', - '-Command', - `& ${quotePowerShellLiteral(process.execPath)} ${quotePowerShellLiteral(scriptPath)} ${quotedArg}` - ], + program: powershell, + args: ['-NoProfile', '-NonInteractive', '-Command', build(native)], cwd: root }) expect(result.code, JSON.stringify(result)).toBe(0) @@ -32,19 +38,120 @@ 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 => + captureNativeArgv(root, 'powershell.exe', (native) => `${native} ${quoted}`) try { const plain = 'line one\r\nline two $HOME `tick\nO\u2019Brien \u201Cdouble\u201D end' - expect(await captureNativeArgv(root, quotePowerShellLiteral(plain))).toEqual([plain]) + 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 captureNativeArgv(root, quotePowerShellLiteral(withQuotes))).toEqual( - await captureNativeArgv(root, legacy) - ) + 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 }) + } + } + ) + } +)