From 4cafa50ec0a43f87e96da25cbf413af2c6f9593f Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 25 Sep 2026 23:13:31 -0700 Subject: [PATCH] fix(windows): reuse shared PowerShell literal quoting at every hand-rolled escaper (#23083) Co-authored-by: Orca Worker --- src/main/agent-hooks/installer-utils.test.ts | 9 ++++++++ src/main/agent-hooks/installer-utils.ts | 11 ++++------ src/main/claude/hook-settings.ts | 4 ++-- src/main/cli/cli-privileged-processes.test.ts | 21 ++++++++++++++++++- src/main/cli/cli-privileged-processes.ts | 7 ++----- .../runtime/windows-mobile-firewall.test.ts | 12 +++++++++++ src/main/runtime/windows-mobile-firewall.ts | 17 +++++++-------- src/main/window/clipboard-file-copy.test.ts | 11 ++++++++++ src/main/window/clipboard-file-copy.ts | 8 +++---- .../ipynb-kernel-setup-commands.test.ts | 1 + .../editor/ipynb-kernel-setup-commands.ts | 3 ++- src/shared/setup-agent-sequencing.test.ts | 14 +++++++++++++ src/shared/setup-agent-sequencing.ts | 17 +++++++-------- .../setup-agent-sequencing.windows.test.ts | 9 +++----- src/shared/tui-agent-startup-shell.test.ts | 6 ++++++ src/shared/tui-agent-startup-shell.ts | 3 ++- .../windows-cmd-runner-delayed-launch.test.ts | 7 +++++++ .../windows-cmd-runner-delayed-launch.ts | 7 ++----- tests/e2e/helpers/computer-driver.ts | 9 +++----- ...terminal-codex-cursor-jitter-repro.spec.ts | 7 ++----- tests/e2e/terminal-node-command.ts | 8 +++---- 21 files changed, 123 insertions(+), 68 deletions(-) diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 40966c0f725..1a4f087f19b 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -638,6 +638,15 @@ describe('wrapWindowsHookCommand', () => { ) }) + it('doubles typographic single quotes in the script path literal', () => { + const decoded = decodeWindowsHookCommand( + wrapWindowsHookCommand('C:\\Users\\O\u2019Brien\\.orca\\agent-hooks\\codex-hook.cmd') + ) + expect(decoded).toContain( + "Test-Path -LiteralPath 'C:\\Users\\O\u2019\u2019Brien\\.orca\\agent-hooks\\codex-hook.cmd'" + ) + }) + it('scopes environment variables inside the encoded launcher', () => { const command = wrapWindowsHookCommand('C:\\hooks\\copilot-hook.ps1', { ORCA_COPILOT_HOOK_EVENT: 'UserPromptSubmit' diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index a1cd7877638..e6519fdb6f2 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -12,6 +12,7 @@ import { homedir } from 'node:os' import { dirname, join } from 'node:path' import { randomUUID } from 'node:crypto' import type { AgentHookSource } from '../../shared/agent-hook-relay' +import { quotePowerShellLiteral } from '../../shared/powershell-native-argument' import { grantDirAcl, isPermissionError } from '../win32-utils' import { resolveHooksJsonWritePath } from './hook-config-write-path' import { writeRollingFileBackup } from '../rolling-file-backup' @@ -108,10 +109,6 @@ export function getSharedManagedScriptPath(scriptFileName: string): string { export { wrapPosixHookCommand } from './posix-hook-command' -export function quotePowerShellString(value: string): string { - return `'${value.replaceAll("'", "''")}'` -} - export { wrapWindowsPowerShellEncodedCommand, WINDOWS_POWERSHELL_HOOK_SWITCHES @@ -134,14 +131,14 @@ export function buildWindowsHookPowerShellCommand( options: { fallbackStdout?: string } = {} ): string { // Why: the encoded launcher protects paths across Windows shells and drains stdin when the config points at a missing script. - const quoted = quotePowerShellString(scriptPath) + const quoted = quotePowerShellLiteral(scriptPath) const envPrefix = Object.entries(env) - .map(([key, value]) => `$env:${key} = ${quotePowerShellString(value)}; `) + .map(([key, value]) => `$env:${key} = ${quotePowerShellLiteral(value)}; `) .join('') const fallback = options.fallbackStdout === undefined ? '' - : `Write-Output ${quotePowerShellString(options.fallbackStdout)}; ` + : `Write-Output ${quotePowerShellLiteral(options.fallbackStdout)}; ` // Why the order: answer first (a gate event reads silence as deny), then the shared // env guard, and only then own stdin — outside an Orca pane the caller may abandon the // pipe, and ReadToEnd would strand the launcher there forever (#11549). diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 92ffa9606a2..42a986cad20 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -6,13 +6,13 @@ import { getSharedManagedScriptPath, isPlainObject, MANAGED_HOOK_TIMEOUT_SECONDS, - quotePowerShellString, removeManagedCommands, wrapWindowsPowerShellEncodedCommand, type HookCommandConfig, type HookDefinition, type HooksConfig } from '../agent-hooks/installer-utils' +import { quotePowerShellLiteral } from '../../shared/powershell-native-argument' import { wrapRuntimeHomeHookCommand } from '../agent-hooks/runtime-home-hook-command' import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { isGitBashAvailable } from '../git-bash' @@ -192,7 +192,7 @@ export function getWindowsManagedLifecycleHook( } const scriptFileName = win32.basename(scriptPath) // Why: runtime profile resolution keeps the managed entry portable across users (STA-3348). - const quotedRelativePath = quotePowerShellString(`.orca\\agent-hooks\\${scriptFileName}`) + const quotedRelativePath = quotePowerShellLiteral(`.orca\\agent-hooks\\${scriptFileName}`) // Why: compat consumers require neutral JSON even when the managed script is missing (#14818). const innerCommand = `$scriptPath = Join-Path $env:USERPROFILE ${quotedRelativePath}; ` + diff --git a/src/main/cli/cli-privileged-processes.test.ts b/src/main/cli/cli-privileged-processes.test.ts index e391df0bc6b..16d59af2df9 100644 --- a/src/main/cli/cli-privileged-processes.test.ts +++ b/src/main/cli/cli-privileged-processes.test.ts @@ -4,7 +4,11 @@ const runProcessMock = vi.hoisted(() => vi.fn()) vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) -import { runMacPrivilegedCommand, runWindowsPathCommand } from './cli-privileged-processes' +import { + runMacPrivilegedCommand, + runWindowsPathCommand, + writeWindowsUserPath +} from './cli-privileged-processes' describe('Windows CLI PATH process boundary', () => { beforeEach(() => runProcessMock.mockReset()) @@ -55,6 +59,21 @@ describe('Windows CLI PATH process boundary', () => { stderr: 'UnauthorizedAccessException' }) }) + + it('doubles typographic single quotes in the PATH literal', async () => { + runProcessMock.mockResolvedValue({ + code: 0, + signal: null, + stdout: '', + stderr: '', + timedOut: false + }) + + await writeWindowsUserPath('C:\\O\u2019Brien\\bin') + expect(runProcessMock.mock.calls[0][0].args.at(-1)).toBe( + "[Environment]::SetEnvironmentVariable('Path', 'C:\\O\u2019\u2019Brien\\bin', 'User')" + ) + }) }) describe('macOS CLI privileged process boundary', () => { diff --git a/src/main/cli/cli-privileged-processes.ts b/src/main/cli/cli-privileged-processes.ts index 70e9e3cfb70..89acc2542e0 100644 --- a/src/main/cli/cli-privileged-processes.ts +++ b/src/main/cli/cli-privileged-processes.ts @@ -1,4 +1,5 @@ import { runProcess } from '../../shared/child-process/run-process' +import { quotePowerShellLiteral } from '../../shared/powershell-native-argument' import { WINDOWS_PATH_WRITE_TIMEOUT_MS } from './cli-install-constants' export async function runMacPrivilegedCommand(command: string): Promise { @@ -21,7 +22,7 @@ export async function writeWindowsUserPath(value: string): Promise { await runWindowsPathCommand([ '-NoProfile', '-Command', - `[Environment]::SetEnvironmentVariable('Path', ${quotePowerShell(value)}, 'User')` + `[Environment]::SetEnvironmentVariable('Path', ${quotePowerShellLiteral(value)}, 'User')` ]) } @@ -49,7 +50,3 @@ function processFailure( Object.assign(error, { code: result.code, stderr: result.stderr }) return error } - -function quotePowerShell(value: string): string { - return `'${value.replaceAll("'", "''")}'` -} diff --git a/src/main/runtime/windows-mobile-firewall.test.ts b/src/main/runtime/windows-mobile-firewall.test.ts index d561878a538..162f46e682b 100644 --- a/src/main/runtime/windows-mobile-firewall.test.ts +++ b/src/main/runtime/windows-mobile-firewall.test.ts @@ -67,6 +67,18 @@ describe('windows mobile firewall', () => { expect(script).toContain('$localPrefixLength = [int]$ip.PrefixLength') }) + it('doubles typographic single quotes in the executable literal', async () => { + const runPowerShell = vi.fn().mockResolvedValue('not json') + await inspectWindowsMobileFirewall( + 6768, + '192.168.0.108', + environment(runPowerShell, { executablePath: 'C:\\Users\\O\u2019Brien\\Orca\\Orca.exe' }) + ) + expect(runPowerShell.mock.calls[0]![0]).toContain( + "-Program 'C:\\Users\\O\u2019\u2019Brien\\Orca\\Orca.exe'" + ) + }) + it('treats an overlapping inbound Block rule as overriding a matching Allow rule', async () => { const runPowerShell = vi.fn().mockResolvedValue( JSON.stringify({ diff --git a/src/main/runtime/windows-mobile-firewall.ts b/src/main/runtime/windows-mobile-firewall.ts index c90a025cb14..5d17bf5f5a8 100644 --- a/src/main/runtime/windows-mobile-firewall.ts +++ b/src/main/runtime/windows-mobile-firewall.ts @@ -5,6 +5,7 @@ import type { WindowsMobileFirewallStatus, WindowsNetworkCategory } from '../../shared/windows-mobile-firewall' +import { quotePowerShellLiteral } from '../../shared/powershell-native-argument' import { hasSufficientWindowsFirewallRemoteScope } from './windows-firewall-remote-scope' const FIREWALL_RULE_NAME = 'Orca.MobilePairing' @@ -158,15 +159,11 @@ function parseNetworkCategory(value: string): WindowsNetworkCategory { return 'unknown' } -function quotePowerShell(value: string): string { - return `'${value.replaceAll("'", "''")}'` -} - function buildInspectionScript(port: number, executablePath: string, address?: string): string { const addressLookup = address ? ` try { - $ip = Get-NetIPAddress -IPAddress ${quotePowerShell(address)} -ErrorAction Stop | Select-Object -First 1 + $ip = Get-NetIPAddress -IPAddress ${quotePowerShellLiteral(address)} -ErrorAction Stop | Select-Object -First 1 $localAddress = [string]$ip.IPAddress $localPrefixLength = [int]$ip.PrefixLength $profile = Get-NetConnectionProfile -InterfaceIndex $ip.InterfaceIndex -ErrorAction Stop | Select-Object -First 1 @@ -180,7 +177,7 @@ try { return `$ErrorActionPreference = 'Stop' $matchingRuleScopes = @() $blockingRuleDetected = $false -$rules = @(Get-NetFirewallApplicationFilter -PolicyStore ActiveStore -Program ${quotePowerShell(executablePath)} -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' }) +$rules = @(Get-NetFirewallApplicationFilter -PolicyStore ActiveStore -Program ${quotePowerShellLiteral(executablePath)} -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' }) foreach ($rule in $rules) { $portFilter = $rule | Get-NetFirewallPortFilter $protocol = [string]$portFilter.Protocol @@ -215,7 +212,7 @@ function buildRepairScript(port: number, executablePath: string): string { // Removal deliberately ignores the Block rule's remote-address scope, // mirroring the fail-closed inspection (the phone address is unknown). return `$ErrorActionPreference = 'Stop' -$blockingRules = @(Get-NetFirewallApplicationFilter -Program ${quotePowerShell(executablePath)} -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Block' }) +$blockingRules = @(Get-NetFirewallApplicationFilter -Program ${quotePowerShellLiteral(executablePath)} -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Block' }) foreach ($rule in $blockingRules) { $portFilter = $rule | Get-NetFirewallPortFilter $protocol = [string]$portFilter.Protocol @@ -225,8 +222,8 @@ foreach ($rule in $blockingRules) { $rule | Remove-NetFirewallRule } } -Get-NetFirewallRule -Name ${quotePowerShell(FIREWALL_RULE_NAME)} -ErrorAction SilentlyContinue | Remove-NetFirewallRule -New-NetFirewallRule -Name ${quotePowerShell(FIREWALL_RULE_NAME)} -DisplayName ${quotePowerShell(FIREWALL_RULE_DISPLAY_NAME)} -Description 'Allows Orca Mobile to connect to this Orca desktop on private networks.' -Direction Inbound -Action Allow -Enabled True -Profile Private -Protocol TCP -LocalPort ${port} -Program ${quotePowerShell(executablePath)} -EdgeTraversalPolicy Block | Out-Null` +Get-NetFirewallRule -Name ${quotePowerShellLiteral(FIREWALL_RULE_NAME)} -ErrorAction SilentlyContinue | Remove-NetFirewallRule +New-NetFirewallRule -Name ${quotePowerShellLiteral(FIREWALL_RULE_NAME)} -DisplayName ${quotePowerShellLiteral(FIREWALL_RULE_DISPLAY_NAME)} -Description 'Allows Orca Mobile to connect to this Orca desktop on private networks.' -Direction Inbound -Action Allow -Enabled True -Profile Private -Protocol TCP -LocalPort ${port} -Program ${quotePowerShellLiteral(executablePath)} -EdgeTraversalPolicy Block | Out-Null` } // Why the elevated child keeps `-EncodedCommand` while the local runner does not: `Start-Process @@ -237,7 +234,7 @@ New-NetFirewallRule -Name ${quotePowerShell(FIREWALL_RULE_NAME)} -DisplayName ${ function buildElevationScript(powershellPath: string, encodedRepairScript: string): string { return `$ErrorActionPreference = 'Stop' try { - $process = Start-Process -FilePath ${quotePowerShell(powershellPath)} -ArgumentList @('-NoProfile', '-NonInteractive', '-EncodedCommand', '${encodedRepairScript}') -Verb RunAs -Wait -PassThru + $process = Start-Process -FilePath ${quotePowerShellLiteral(powershellPath)} -ArgumentList @('-NoProfile', '-NonInteractive', '-EncodedCommand', '${encodedRepairScript}') -Verb RunAs -Wait -PassThru [pscustomobject]@{ launched = $true; exitCode = $process.ExitCode } | ConvertTo-Json -Compress } catch { [pscustomobject]@{ launched = $false; nativeErrorCode = $_.Exception.NativeErrorCode } | ConvertTo-Json -Compress diff --git a/src/main/window/clipboard-file-copy.test.ts b/src/main/window/clipboard-file-copy.test.ts index d1142705a92..dfc58eb7e1c 100644 --- a/src/main/window/clipboard-file-copy.test.ts +++ b/src/main/window/clipboard-file-copy.test.ts @@ -91,6 +91,17 @@ describe('writeFileToClipboard', () => { expect(args.join(' ')).toContain("Set-Clipboard -LiteralPath '/repo/o''brien.png'") }) + it('doubles typographic single quotes, which PowerShell also treats as delimiters', async () => { + const runCommand = vi.fn(async (_command: string, _args: string[]) => {}) + await writeFileToClipboard( + '/repo/o\u2019brien.png', + makeDeps({ platform: 'win32', runCommand }) + ) + expect(runCommand.mock.calls[0][1].join(' ')).toContain( + "Set-Clipboard -LiteralPath '/repo/o\u2019\u2019brien.png'" + ) + }) + it('reports a failure (never throws) when PowerShell rejects on Windows', async () => { const runCommand = vi.fn(async (_command: string, _args: string[]) => { throw new Error('powershell.exe not found') diff --git a/src/main/window/clipboard-file-copy.ts b/src/main/window/clipboard-file-copy.ts index 63f5594f279..bc09c27c19c 100644 --- a/src/main/window/clipboard-file-copy.ts +++ b/src/main/window/clipboard-file-copy.ts @@ -1,5 +1,6 @@ import { isAbsolute } from 'node:path' import { pathToFileURL } from 'node:url' +import { quotePowerShellLiteral } from '../../shared/powershell-native-argument' export type ClipboardFileResult = { ok: boolean; reason?: string } @@ -47,15 +48,14 @@ export async function writeFileToClipboard( if (deps.platform === 'win32') { // Set-Clipboard -LiteralPath populates CF_HDROP, which Explorer pastes as a - // file. Single-quote escaping for the PowerShell string literal. Guard the - // spawn so a missing/erroring PowerShell surfaces as a result, not a throw. - const escaped = clipboardPath.replace(/'/g, "''") + // file. Guard the spawn so a missing/erroring PowerShell surfaces as a + // result, not a throw. try { await deps.runCommand('powershell.exe', [ '-NoProfile', '-NonInteractive', '-Command', - `Set-Clipboard -LiteralPath '${escaped}'` + `Set-Clipboard -LiteralPath ${quotePowerShellLiteral(clipboardPath)}` ]) return { ok: true } } catch { diff --git a/src/renderer/src/components/editor/ipynb-kernel-setup-commands.test.ts b/src/renderer/src/components/editor/ipynb-kernel-setup-commands.test.ts index 873dc2bb0a4..599cfac726c 100644 --- a/src/renderer/src/components/editor/ipynb-kernel-setup-commands.test.ts +++ b/src/renderer/src/components/editor/ipynb-kernel-setup-commands.test.ts @@ -13,6 +13,7 @@ describe('copyable setup commands', () => { expect(windows('C:\\My Env\\python.exe')).toBe(`& 'C:\\My Env\\python.exe'${pip}`) expect(windows('C:\\Dev&Test\\python.exe')).toBe(`& 'C:\\Dev&Test\\python.exe'${pip}`) expect(windows("C:\\Bob's\\python.exe")).toBe(`& 'C:\\Bob''s\\python.exe'${pip}`) + expect(windows('C:\\Bob\u2019s\\python.exe')).toBe(`& 'C:\\Bob\u2019\u2019s\\python.exe'${pip}`) }) it('creates the venv and installs into it only when creation succeeded', () => { diff --git a/src/renderer/src/components/editor/ipynb-kernel-setup-commands.ts b/src/renderer/src/components/editor/ipynb-kernel-setup-commands.ts index 0d9fa553de8..dd15a8586c5 100644 --- a/src/renderer/src/components/editor/ipynb-kernel-setup-commands.ts +++ b/src/renderer/src/components/editor/ipynb-kernel-setup-commands.ts @@ -1,9 +1,10 @@ import { getRendererAppPlatform } from '@/lib/renderer-app-platform' import { venvInterpreterSegments } from '../../../../shared/notebook-venv-location' +import { quotePowerShellLiteral } from '../../../../shared/powershell-native-argument' /** Quotes a path for a shell; single quotes are literal in POSIX shells and PowerShell. */ function shellQuote(path: string, windows: boolean): string { - return windows ? `'${path.replaceAll("'", "''")}'` : `'${path.replaceAll("'", "'\\''")}'` + return windows ? quotePowerShellLiteral(path) : `'${path.replaceAll("'", "'\\''")}'` } /** PowerShell runs a quoted program path only through `&`. */ diff --git a/src/shared/setup-agent-sequencing.test.ts b/src/shared/setup-agent-sequencing.test.ts index 1b8d69f0aa5..908ea149cef 100644 --- a/src/shared/setup-agent-sequencing.test.ts +++ b/src/shared/setup-agent-sequencing.test.ts @@ -122,6 +122,20 @@ describe('createSequencedSetupAgentCommands', () => { ) }) + it('doubles typographic single quotes in native Windows gate literals', () => { + const commands = createSequencedSetupAgentCommands({ + runnerScriptPath: 'C:\\O\u2019Brien\\.git\\orca\\setup-runner.cmd', + platform: 'windows', + startupCommand: 'codex', + nonce: 'nonce-3' + }) + const decoded = Buffer.from( + commands.setupCommand.split('-EncodedCommand ')[1] ?? '', + 'base64' + ).toString('utf16le') + expect(decoded).toContain("$runner = 'C:\\O\u2019\u2019Brien\\.git\\orca\\setup-runner.cmd'") + }) + it('leaves the failure and timeout messages as the only other outcomes', () => { const script = createSequencedSetupAgentCommands({ diff --git a/src/shared/setup-agent-sequencing.ts b/src/shared/setup-agent-sequencing.ts index 43f22c9bdcd..a865b23f837 100644 --- a/src/shared/setup-agent-sequencing.ts +++ b/src/shared/setup-agent-sequencing.ts @@ -7,6 +7,7 @@ import { type SetupRunnerShell } from './setup-runner-command' import { createNonSecureContextUuid } from './non-secure-context-uuid' +import { quotePowerShellLiteral } from './powershell-native-argument' const DEFAULT_WAIT_TIMEOUT_SECONDS = 2 * 60 * 60 // Exported so the gate and its tests share one definition. @@ -193,10 +194,10 @@ function buildWindowsSetupCommand( ): string { // Why: delayed expansion keeps path metacharacters as data when cmd invokes the batch runner. const script = [ - `$runner = ${quotePowerShellString(runnerScriptPath)}`, - `$marker = ${quotePowerShellString(markerPath)}`, + `$runner = ${quotePowerShellLiteral(runnerScriptPath)}`, + `$marker = ${quotePowerShellLiteral(markerPath)}`, '$tmp = $marker + ".tmp"', - `$nonce = ${quotePowerShellString(nonce)}`, + `$nonce = ${quotePowerShellLiteral(nonce)}`, 'Remove-Item -LiteralPath $marker, $tmp -Force -ErrorAction SilentlyContinue', '$processInfo = [System.Diagnostics.ProcessStartInfo]::new()', '$processInfo.FileName = $env:ComSpec', @@ -245,13 +246,13 @@ function buildWindowsStartupCommand( '"session (" + $_.FullyQualifiedErrorId + "). A startup command that runs a .ps1 " + ' + '"may be blocked.") }', '$ProgressPreference = $orcaProgress', - `$marker = ${quotePowerShellString(markerPath)}`, + `$marker = ${quotePowerShellLiteral(markerPath)}`, 'if ([string]::IsNullOrWhiteSpace($marker)) {', ' [Console]::Error.WriteLine("Missing setup marker path.")', ' exit 1', '}', '$tmp = $marker + ".tmp"', - `$nonce = ${quotePowerShellString(nonce)}`, + `$nonce = ${quotePowerShellLiteral(nonce)}`, `$deadline = (Get-Date).AddSeconds(${timeout})`, '[Console]::Error.WriteLine("Waiting for setup to finish before starting agent...")', 'while ($true) {', @@ -269,7 +270,7 @@ function buildWindowsStartupCommand( ' [Console]::Error.WriteLine("Missing sequenced startup command.")', ' exit 1', ' }', - ` [Console]::Error.WriteLine(${quotePowerShellString(SETUP_COMPLETE_MESSAGE)})`, + ` [Console]::Error.WriteLine(${quotePowerShellLiteral(SETUP_COMPLETE_MESSAGE)})`, ' Invoke-Expression $startup', ' if ($global:LASTEXITCODE -ne $null) { exit $global:LASTEXITCODE }', ' if (-not $?) { exit 1 }', @@ -301,10 +302,6 @@ function quotePosixArg(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } -function quotePowerShellString(value: string): string { - return `'${value.replace(/'/g, "''")}'` -} - export function getSetupAgentSequenceShellForTests( runnerScriptPath: string, platform: SetupRunnerCommandPlatform diff --git a/src/shared/setup-agent-sequencing.windows.test.ts b/src/shared/setup-agent-sequencing.windows.test.ts index 10b1e76d908..b27ee5ccc41 100644 --- a/src/shared/setup-agent-sequencing.windows.test.ts +++ b/src/shared/setup-agent-sequencing.windows.test.ts @@ -9,6 +9,7 @@ import { createSequencedSetupAgentCommands, SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from './setup-agent-sequencing' +import { quotePowerShellLiteral } from './powershell-native-argument' const TEMP_DIRS: string[] = [] const WINDOWS_PROCESS_TEST_TIMEOUT_MS = 30_000 @@ -49,14 +50,14 @@ describe.skipIf(process.platform !== 'win32')('Windows setup-agent sequencing', [ 'param([string]$Value)', '$utf8 = [System.Text.UTF8Encoding]::new($false)', - `[System.IO.File]::AppendAllText('${quotePowerShell(logPath)}', $Value + [Environment]::NewLine, $utf8)` + `[System.IO.File]::AppendAllText(${quotePowerShellLiteral(logPath)}, $Value + [Environment]::NewLine, $utf8)` ].join('\r\n'), 'utf8' ) const commands = createSequencedSetupAgentCommands({ runnerScriptPath, - startupCommand: `& '${quotePowerShell(startupScriptPath)}' '${quotePowerShell(prompt)}'`, + startupCommand: `& ${quotePowerShellLiteral(startupScriptPath)} ${quotePowerShellLiteral(prompt)}`, platform: 'windows', nonce: 'windows-sequence', waitTimeoutSeconds: 2 @@ -167,10 +168,6 @@ function spawnWindowsCommand( }) } -function quotePowerShell(value: string): string { - return value.replace(/'/g, "''") -} - function waitForExit( child: ReturnType ): Promise<{ code: number | null; stderr: string }> { diff --git a/src/shared/tui-agent-startup-shell.test.ts b/src/shared/tui-agent-startup-shell.test.ts index 1698e86b08b..39c46e5fbf3 100644 --- a/src/shared/tui-agent-startup-shell.test.ts +++ b/src/shared/tui-agent-startup-shell.test.ts @@ -65,6 +65,12 @@ describe('tokenizeStartupCommand spans (windows shells)', () => { }) }) +describe('powershell startup argument quoting', () => { + it('doubles typographic single quotes, which PowerShell also treats as delimiters', () => { + expect(quoteStartupArg('it\u2019s', 'powershell')).toBe("'it\u2019\u2019s'") + }) +}) + describe('one Unix startup dialect', () => { it('clears variables with a self-contained branch, not a per-shell builtin', () => { // Why not `unset`/`set -e` alone, and why not a wrapper-defined helper: diff --git a/src/shared/tui-agent-startup-shell.ts b/src/shared/tui-agent-startup-shell.ts index 94df0f80ac5..83bfbd584d2 100644 --- a/src/shared/tui-agent-startup-shell.ts +++ b/src/shared/tui-agent-startup-shell.ts @@ -1,4 +1,5 @@ import { tokenizeCustomCommandTemplate, type CommandTokenSpan } from './commit-message-prompt' +import { quotePowerShellLiteral } from './powershell-native-argument' /** * `'posix'` covers every Unix shell Orca can type into, fish included — not @@ -217,7 +218,7 @@ function quotePortableUnixArg(value: string): string { export function quoteStartupArg(value: string, shell: AgentStartupShell): string { if (shell === 'powershell') { - return `'${value.replace(/'/g, "''")}'` + return quotePowerShellLiteral(value) } if (shell === 'cmd') { return `"${value.replace(/([\^&|<>()%!"])/g, '^$1')}"` diff --git a/src/shared/windows-cmd-runner-delayed-launch.test.ts b/src/shared/windows-cmd-runner-delayed-launch.test.ts index 21c5cc8befe..af3e409484c 100644 --- a/src/shared/windows-cmd-runner-delayed-launch.test.ts +++ b/src/shared/windows-cmd-runner-delayed-launch.test.ts @@ -33,4 +33,11 @@ describe('buildWindowsCmdRunnerDelayedLaunchCommand', () => { expect(script).toContain("$runner = 'C:\\work (x86)\\se&tup.cmd'") expect(script).toContain('/d /s /v:on /c ""!ORCA_SETUP_RUNNER!""') }) + + it('doubles typographic single quotes in the runner path literal', () => { + const script = decodePayload( + buildWindowsCmdRunnerDelayedLaunchCommand('C:\\O\u2019Brien\\setup.cmd') + ) + expect(script).toContain("$runner = 'C:\\O\u2019\u2019Brien\\setup.cmd'") + }) }) diff --git a/src/shared/windows-cmd-runner-delayed-launch.ts b/src/shared/windows-cmd-runner-delayed-launch.ts index cdc0af3a48a..29403b26362 100644 --- a/src/shared/windows-cmd-runner-delayed-launch.ts +++ b/src/shared/windows-cmd-runner-delayed-launch.ts @@ -1,4 +1,5 @@ import { encodePowerShellCommand } from './powershell-command-encoding' +import { quotePowerShellLiteral } from './powershell-native-argument' // Why: `cmd.exe /c ""` is typed into the terminal's shell, so the path is parsed twice. // cmd expands %VAR% even inside quotes (no escape exists on the command line), and PowerShell @@ -18,7 +19,7 @@ export function windowsRunnerPathNeedsCmdGuard(runnerScriptPath: string): boolea */ export function buildWindowsCmdRunnerDelayedLaunchCommand(runnerScriptPath: string): string { const script = [ - `$runner = ${quotePowerShellString(runnerScriptPath)}`, + `$runner = ${quotePowerShellLiteral(runnerScriptPath)}`, // Why: an empty value would silently degrade to `cmd /c ""`, which exits 0 without running setup. 'if ([string]::IsNullOrEmpty($runner)) { exit 1 }', '$processInfo = [System.Diagnostics.ProcessStartInfo]::new()', @@ -39,7 +40,3 @@ export function buildWindowsCmdRunnerDelayedLaunchCommand(runnerScriptPath: stri // base64 stays: this string is typed into a shell, which is the whole point of the guard above. return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encodePowerShellCommand(script)}` } - -function quotePowerShellString(value: string): string { - return `'${value.replace(/'/g, "''")}'` -} diff --git a/tests/e2e/helpers/computer-driver.ts b/tests/e2e/helpers/computer-driver.ts index bc7e7a0acb8..aa1c8cecb8f 100644 --- a/tests/e2e/helpers/computer-driver.ts +++ b/tests/e2e/helpers/computer-driver.ts @@ -10,6 +10,7 @@ import { stopOrcaRuntime, type CliResult } from './computer-cli-driver' +import { quotePowerShellLiteral } from '../../../src/shared/powershell-native-argument' const execFileAsync = promisify(execFile) let textEditTempDir: string | null = null @@ -185,7 +186,7 @@ export async function ensureNotepadLaunched(): Promise { '-NoProfile', '-NonInteractive', '-Command', - `Start-Process notepad.exe -ArgumentList ${powerShellSingleQuoted(filePath)}` + `Start-Process notepad.exe -ArgumentList ${quotePowerShellLiteral(filePath)}` ]) notepadAppSelector = `pid:${await findNotepadWindowPid(filePath)}` } @@ -232,7 +233,7 @@ function delay(ms: number): Promise { async function findNotepadWindowPid(filePath: string): Promise { const targetName = filePath.split(/[\\/]/).at(-1) ?? filePath const script = [ - `$targetName = ${powerShellSingleQuoted(targetName)}`, + `$targetName = ${quotePowerShellLiteral(targetName)}`, '$deadline = (Get-Date).AddSeconds(15)', '$target = $null', 'while ((Get-Date) -lt $deadline -and $null -eq $target) {', @@ -260,10 +261,6 @@ async function findNotepadWindowPid(filePath: string): Promise { return Number.parseInt(result.stdout.trim(), 10) } -function powerShellSingleQuoted(value: string): string { - return `'${value.replaceAll("'", "''")}'` -} - function escapeAppleScript(value: string): string { return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"') } diff --git a/tests/e2e/terminal-codex-cursor-jitter-repro.spec.ts b/tests/e2e/terminal-codex-cursor-jitter-repro.spec.ts index be3ce0f5003..ad8de6ecd8e 100644 --- a/tests/e2e/terminal-codex-cursor-jitter-repro.spec.ts +++ b/tests/e2e/terminal-codex-cursor-jitter-repro.spec.ts @@ -5,6 +5,7 @@ import { test, expect } from './helpers/orca-app' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { waitForActiveTerminalManager } from './helpers/terminal' import { analyzeRasterCursorCells, type RasterCursorCell } from './terminal-cursor-raster-probe' +import { quotePowerShellLiteral } from '../../src/shared/powershell-native-argument' type ShellCase = { label: string @@ -168,10 +169,6 @@ function isUnexpectedVisibleCursorFrame(frame: ScreenSnapshot): boolean { return !isInputCursorRow(frame, frame.marker.cellY) } -function quotePowerShellSingleQuoted(value: string): string { - return `'${value.replaceAll("'", "''")}'` -} - async function createShellTab( page: Page, shellOverride: ShellCase['shellOverride'] @@ -251,7 +248,7 @@ async function prepareCodexTerminal( const launchCommand = shellCase.shellOverride === 'powershell.exe' - ? `Set-Location -LiteralPath ${quotePowerShellSingleQuoted(process.cwd())}; ${shellCase.codexCommand}` + ? `Set-Location -LiteralPath ${quotePowerShellLiteral(process.cwd())}; ${shellCase.codexCommand}` : shellCase.codexCommand await page.keyboard.insertText(launchCommand) await page.keyboard.press('Enter') diff --git a/tests/e2e/terminal-node-command.ts b/tests/e2e/terminal-node-command.ts index 140316497a5..c85dd813698 100644 --- a/tests/e2e/terminal-node-command.ts +++ b/tests/e2e/terminal-node-command.ts @@ -1,13 +1,11 @@ +import { quotePowerShellLiteral } from '../../src/shared/powershell-native-argument' + function quotePosixShellArg(value: string): string { return `'${value.replaceAll("'", "'\\''")}'` } -function quotePowerShellArg(value: string): string { - return `'${value.replaceAll("'", "''")}'` -} - function quoteTerminalArg(value: string): string { - return process.platform === 'win32' ? quotePowerShellArg(value) : quotePosixShellArg(value) + return process.platform === 'win32' ? quotePowerShellLiteral(value) : quotePosixShellArg(value) } export function nodeTerminalCommand(args: readonly string[]): string {