mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 00:02:37 +00:00
fix(windows): reuse shared PowerShell literal quoting at every hand-rolled escaper (#23083)
Co-authored-by: Orca Worker <orca-worker@localhost>
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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}; ` +
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -21,7 +22,7 @@ export async function writeWindowsUserPath(value: string): Promise<void> {
|
||||
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("'", "''")}'`
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 `&`. */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof spawn>
|
||||
): Promise<{ code: number | null; stderr: string }> {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')}"`
|
||||
|
||||
@@ -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'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { encodePowerShellCommand } from './powershell-command-encoding'
|
||||
import { quotePowerShellLiteral } from './powershell-native-argument'
|
||||
|
||||
// Why: `cmd.exe /c "<path>"` 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, "''")}'`
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
'-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<void> {
|
||||
async function findNotepadWindowPid(filePath: string): Promise<number> {
|
||||
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<number> {
|
||||
return Number.parseInt(result.stdout.trim(), 10)
|
||||
}
|
||||
|
||||
function powerShellSingleQuoted(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`
|
||||
}
|
||||
|
||||
function escapeAppleScript(value: string): string {
|
||||
return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user