Fix Windows setup sequencing wrapper quoting (#8806)

* fix(setup): correct Windows sequencing wrapper quoting

* test(setup): preserve spaced Windows batch paths

* refactor(setup): dedupe PowerShell encoder, clarify wrapCmd comment

Route the Windows setup-sequencing and Hermes startup planners through the
shared renderer-safe encodePowerShellCommand instead of two verbatim btoa
copies, and make that shared encoder renderer-safe (Buffer is unavailable in
the sandboxed renderer where both planners also run). Reword the wrapCmd
comment so it describes the current single-outer-quote behavior instead of the
old quote-doubling bug.

* test(setup): cover Windows metacharacter paths

* fix(setup): keep Windows runner paths out of cmd source

* test(setup): preserve Windows setup failures

* docs(setup): explain safe cmd path handoff

---------

Co-authored-by: OrcaWin <alpha-eng@stably.ai>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
BingZ 2026-07-29 10:58:52 +08:00 committed by GitHub
parent c6c6c71196
commit 9150ac65cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 320 additions and 56 deletions

View File

@ -1,3 +1,4 @@
import { encodePowerShellCommand } from './powershell-command-encoding'
import {
buildShellCommandFromArgv,
quoteStartupArg,
@ -14,15 +15,6 @@ const POWERSHELL_NATIVE_QUERY_VARIABLE = 'orcaHermesNativeQuery'
export const ORCA_HERMES_STARTUP_QUERY_ENV = 'ORCA_HERMES_STARTUP_QUERY'
function encodePowerShellCommand(command: string): string {
let bytes = ''
for (let index = 0; index < command.length; index += 1) {
const code = command.charCodeAt(index)
bytes += String.fromCharCode(code & 0xff, code >>> 8)
}
return btoa(bytes)
}
function encodePosixEvalScript(command: string): string {
return Array.from(
new TextEncoder().encode(command),

View File

@ -1,3 +1,11 @@
export function encodePowerShellCommand(command: string): string {
return Buffer.from(command, 'utf16le').toString('base64')
// Why: some callers (setup sequencing, Hermes startup) run in the sandboxed
// renderer where Node's Buffer is unavailable, so encode the UTF-16LE bytes
// PowerShell's -EncodedCommand expects using only renderer-safe globals.
let bytes = ''
for (let index = 0; index < command.length; index += 1) {
const code = command.charCodeAt(index)
bytes += String.fromCharCode(code & 0xff, code >>> 8)
}
return btoa(bytes)
}

View File

@ -1,5 +1,5 @@
import { spawn } from 'node:child_process'
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
@ -173,33 +173,84 @@ describe('createSequencedSetupAgentCommands', () => {
nonce: 'nonce-win',
waitTimeoutSeconds: 3
})
const setupPowerShell = decodePowerShellScript(result.setupCommand)
const startupPowerShell = decodePowerShellScript(result.startupCommand)
expect(result.setupCommand).toContain('cmd.exe /d /s /v:on /c')
expect(result.setupCommand).toContain('cmd.exe /c ""C:\\repo\\.git\\orca\\setup-runner.cmd""')
expect(result.setupCommand).toContain('echo !ORCA_SETUP_NONCE!:!ORCA_SETUP_STATUS!')
expect(result.setupCommand).toContain(
'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand'
)
expect(setupPowerShell).toContain("$runner = 'C:\\repo\\.git\\orca\\setup-runner.cmd'")
expect(setupPowerShell).toContain('$nonce + ":" + $setupStatus')
expect(result.startupCommand.match(/powershell\.exe/g)).toHaveLength(1)
expect(result.startupCommand).toContain('powershell.exe -NoProfile -ExecutionPolicy Bypass')
expect(result.startupCommand).toContain('AddSeconds(3)')
expect(result.startupCommand).toContain('!ORCA_SETUP_STATUS!')
expect(result.startupCommand).toContain('Timed out waiting for setup before starting agent.')
expect(result.startupCommand).toContain('Setup failed; skipping agent startup.')
expect(result.startupCommand).toContain(
'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand'
)
expect(startupPowerShell).toContain('AddSeconds(3)')
expect(startupPowerShell).toContain('Missing setup marker path.')
expect(startupPowerShell).toContain('Timed out waiting for setup before starting agent.')
expect(startupPowerShell).toContain('Setup failed; skipping agent startup.')
expect(startupPowerShell).toContain(
'Remove-Item -LiteralPath $marker, $tmp -Force -ErrorAction SilentlyContinue'
)
expect(result.startupCommand).not.toContain('%ERRORLEVEL%')
expect(result.startupCommand).not.toContain(' & ) else')
expect(result.startupCommand).not.toContain('if ""!ORCA_SETUP_STATUS!""==""124""')
expect(result.startupCommand).not.toContain('if not ""!ORCA_SETUP_STATUS!""==""0""')
expect(result.startupCommand).not.toContain(
`call !${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}!`
)
expect(result.startupCommand).toContain('Invoke-Expression')
expect(startupPowerShell).toContain('Invoke-Expression')
expect(result.startupCommand).not.toContain('fix !PATH! & test')
expect(result.startupEnv).toEqual({
[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --model gpt-5 'fix !PATH! & test'"
})
})
it.skipIf(process.platform !== 'win32')(
'executes the native Windows setup-to-agent sequence through cmd.exe',
async () => {
const tempDir = join(makeTempDir(), 'path with spaces')
mkdirSync(tempDir)
const runnerScriptPath = join(tempDir, 'setup runner.cmd')
const startupScriptPath = join(tempDir, 'agent-startup.cmd')
const logPath = join(tempDir, 'sequence.log')
writeFileSync(
runnerScriptPath,
['@echo off', `>> "${logPath}" echo setup-done`, 'exit /b 0'].join('\r\n'),
'utf8'
)
writeFileSync(
startupScriptPath,
['@echo off', `>> "${logPath}" echo agent-start`, 'exit /b 0'].join('\r\n'),
'utf8'
)
const commands = createSequencedSetupAgentCommands({
runnerScriptPath,
startupCommand: `cmd.exe /d /c "${startupScriptPath}"`,
platform: 'windows',
nonce: 'windows-sequence',
waitTimeoutSeconds: 2
})
const setupExit = await waitForExit(
spawnWindowsCommand(tempDir, 'run-setup.cmd', commands.setupCommand)
)
expect(setupExit.code).toBe(0)
expect(readIfExists(`${runnerScriptPath}.windows-sequence.done`)).toBe(
'windows-sequence:0\r\n'
)
const startupExit = await waitForExit(
spawnWindowsCommand(
tempDir,
'run-startup.cmd',
commands.startupCommand,
commands.startupEnv
)
)
expect(startupExit.code).toBe(0)
expect(startupExit.stderr).toContain('Waiting for setup to finish before starting agent...')
expect(readFileSync(logPath, 'utf8')).toBe('setup-done\r\nagent-start\r\n')
}
)
it.skipIf(process.platform === 'win32')(
'ignores stale markers until the matching setup run finishes, even when startup launches first',
async () => {
@ -409,6 +460,30 @@ function sleep(ms: number): Promise<void> {
})
}
function spawnWindowsCommand(
dir: string,
filename: string,
command: string,
env: Record<string, string> = {}
): ReturnType<typeof spawn> {
const scriptPath = join(dir, filename)
// Why: /s strips the quotes Node adds for batch paths containing spaces;
// argv spawning still exercises cmd.exe's native parser without that loss.
writeFileSync(scriptPath, `@echo off\r\n${command}\r\nexit /b %ERRORLEVEL%\r\n`, 'utf8')
return spawn('cmd.exe', ['/d', '/c', scriptPath], {
stdio: 'pipe',
env: { ...process.env, ...env }
})
}
function decodePowerShellScript(command: string): string {
const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)?.[1]
if (!encoded) {
throw new Error('Missing PowerShell encoded command')
}
return Buffer.from(encoded, 'base64').toString('utf16le')
}
function waitForExit(
child: ReturnType<typeof spawn>
): Promise<{ code: number | null; stderr: string }> {

View File

@ -1,3 +1,4 @@
import { encodePowerShellCommand } from './powershell-command-encoding'
import {
resolveSetupRunnerCommand,
type SetupRunnerCommandPlatform,
@ -45,7 +46,11 @@ export function createSequencedSetupAgentCommands(args: {
if (resolution.shell === 'windows') {
return {
setupCommand: buildWindowsSetupCommand(resolution.command, markerPath, nonce),
setupCommand: buildWindowsSetupCommand(
resolution.runnerScriptPathForShell,
markerPath,
nonce
),
startupCommand: buildWindowsStartupCommand(markerPath, nonce, waitTimeoutSeconds),
startupEnv: {
[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: args.startupCommand
@ -165,17 +170,33 @@ function hasUnquotedPosixCommandSeparator(command: string): boolean {
return false
}
function buildWindowsSetupCommand(setupCommand: string, markerPath: string, nonce: string): string {
return wrapCmd([
`set "ORCA_SETUP_MARKER=${escapeCmdSetValue(markerPath)}"`,
`set "ORCA_SETUP_NONCE=${escapeCmdSetValue(nonce)}"`,
'del /f /q "!ORCA_SETUP_MARKER!" "!ORCA_SETUP_MARKER!.tmp" 2>nul',
`call ${setupCommand}`,
'set "ORCA_SETUP_STATUS=!ERRORLEVEL!"',
'> "!ORCA_SETUP_MARKER!.tmp" echo !ORCA_SETUP_NONCE!:!ORCA_SETUP_STATUS!',
'move /y "!ORCA_SETUP_MARKER!.tmp" "!ORCA_SETUP_MARKER!" >nul',
'exit /b !ORCA_SETUP_STATUS!'
])
function buildWindowsSetupCommand(
runnerScriptPath: string,
markerPath: string,
nonce: string
): string {
// Why: delayed expansion keeps path metacharacters as data when cmd invokes the batch runner.
const script = [
`$runner = ${quotePowerShellString(runnerScriptPath)}`,
`$marker = ${quotePowerShellString(markerPath)}`,
'$tmp = $marker + ".tmp"',
`$nonce = ${quotePowerShellString(nonce)}`,
'Remove-Item -LiteralPath $marker, $tmp -Force -ErrorAction SilentlyContinue',
'$processInfo = [System.Diagnostics.ProcessStartInfo]::new()',
'$processInfo.FileName = $env:ComSpec',
'$processInfo.Arguments = \'/d /s /v:on /c ""!ORCA_SETUP_RUNNER!""\'',
'$processInfo.UseShellExecute = $false',
'$processInfo.EnvironmentVariables["ORCA_SETUP_RUNNER"] = $runner',
'$process = [System.Diagnostics.Process]::Start($processInfo)',
'$process.WaitForExit()',
'$setupStatus = $process.ExitCode',
'$utf8 = [System.Text.UTF8Encoding]::new($false)',
'[System.IO.File]::WriteAllText($tmp, ($nonce + ":" + $setupStatus + [Environment]::NewLine), $utf8)',
'Move-Item -LiteralPath $tmp -Destination $marker -Force',
'exit $setupStatus'
].join('; ')
return encodePowerShellInvocation(script)
}
function buildWindowsStartupCommand(
@ -187,10 +208,15 @@ function buildWindowsStartupCommand(
// Why: native Windows setup runners launch through cmd.exe, but PowerShell
// gives us safe bounded file polling/parsing without a fragile batch label loop.
const script = [
'$marker = $env:ORCA_SETUP_MARKER',
`$marker = ${quotePowerShellString(markerPath)}`,
'if ([string]::IsNullOrWhiteSpace($marker)) {',
' [Console]::Error.WriteLine("Missing setup marker path.")',
' exit 1',
'}',
'$tmp = $marker + ".tmp"',
'$nonce = $env:ORCA_SETUP_NONCE',
`$nonce = ${quotePowerShellString(nonce)}`,
`$deadline = (Get-Date).AddSeconds(${timeout})`,
'[Console]::Error.WriteLine("Waiting for setup to finish before starting agent...")',
'while ($true) {',
' if (Test-Path -LiteralPath $marker) {',
' $content = Get-Content -LiteralPath $marker -TotalCount 1',
@ -220,18 +246,11 @@ function buildWindowsStartupCommand(
'}'
].join('; ')
return wrapCmd([
`set "ORCA_SETUP_MARKER=${escapeCmdSetValue(markerPath)}"`,
`set "ORCA_SETUP_NONCE=${escapeCmdSetValue(nonce)}"`,
'echo Waiting for setup to finish before starting agent... 1>&2',
`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${quoteWindowsArg(script)}`,
'set "ORCA_SETUP_STATUS=!ERRORLEVEL!"',
'exit /b !ORCA_SETUP_STATUS!'
])
return encodePowerShellInvocation(script)
}
function wrapCmd(parts: string[]): string {
return `cmd.exe /d /s /v:on /c ${quoteWindowsArg(parts.join(' & '))}`
function encodePowerShellInvocation(script: string): string {
return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShellCommand(script)}`
}
function quotePosixArg(value: string): string {
@ -241,12 +260,8 @@ function quotePosixArg(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`
}
function quoteWindowsArg(value: string): string {
return `"${value.replace(/"/g, '""')}"`
}
function escapeCmdSetValue(value: string): string {
return value.replace(/"/g, '""').replace(/[%!^]/g, (char) => `^${char}`)
function quotePowerShellString(value: string): string {
return `'${value.replace(/'/g, "''")}'`
}
export function getSetupAgentSequenceShellForTests(

View File

@ -0,0 +1,174 @@
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it } from 'vitest'
import {
createSequencedSetupAgentCommands,
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV
} from './setup-agent-sequencing'
const TEMP_DIRS: string[] = []
afterEach(() => {
for (const dir of TEMP_DIRS.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
describe.skipIf(process.platform !== 'win32')('Windows setup-agent sequencing', () => {
it.each([
'path with spaces',
'ampersand&parentheses(test)',
'caret^percent%bang!',
"apostrophe's directory",
'Unicode-한글-abc'
])('preserves the native runner path in %s', async (directoryName) => {
const tempDir = makeTempDir(directoryName)
const runnerScriptPath = join(tempDir, 'setup runner.cmd')
const startupScriptPath = join(tempDir, 'agent startup.ps1')
const logPath = join(dirname(tempDir), 'sequence.log')
const prompt = 'spaces & pipe | caret ^ percent % bang ! "quotes" Unicode 한글 trailing\\'
writeFileSync(
runnerScriptPath,
['@echo off', `>> "${logPath}" echo setup-done`, 'exit /b 0'].join('\r\n'),
'utf8'
)
writeFileSync(
startupScriptPath,
[
'param([string]$Value)',
'$utf8 = [System.Text.UTF8Encoding]::new($false)',
`[System.IO.File]::AppendAllText('${quotePowerShell(logPath)}', $Value + [Environment]::NewLine, $utf8)`
].join('\r\n'),
'utf8'
)
const commands = createSequencedSetupAgentCommands({
runnerScriptPath,
startupCommand: `& '${quotePowerShell(startupScriptPath)}' '${quotePowerShell(prompt)}'`,
platform: 'windows',
nonce: 'windows-sequence',
waitTimeoutSeconds: 2
})
const setupExit = await waitForExit(
spawnWindowsCommand(dirname(tempDir), 'run setup.cmd', commands.setupCommand)
)
expect(setupExit.code).toBe(0)
expect(readFileSync(`${runnerScriptPath}.windows-sequence.done`, 'utf8')).toBe(
'windows-sequence:0\r\n'
)
const startupExit = await waitForExit(
spawnWindowsCommand(
dirname(tempDir),
'run startup.cmd',
commands.startupCommand,
commands.startupEnv
)
)
expect(startupExit.code).toBe(0)
expect(startupExit.stderr).toContain('Waiting for setup to finish before starting agent...')
expect(readFileSync(logPath, 'utf8')).toBe(`setup-done\r\n${prompt}\r\n`)
})
it('keeps the startup command out of generated cmd.exe source', () => {
const startupCommand = 'agent --prompt "& | ^ % ! 한글 trailing\\"'
const commands = createSequencedSetupAgentCommands({
runnerScriptPath: 'C:\\repo\\setup-runner.cmd',
startupCommand,
platform: 'windows',
nonce: 'windows-sequence'
})
expect(commands.setupCommand).not.toContain(startupCommand)
expect(commands.startupCommand).not.toContain(startupCommand)
expect(commands.startupEnv?.[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]).toBe(startupCommand)
})
it('propagates setup failure without launching the agent', async () => {
const tempDir = makeTempDir('failure path & metacharacters!')
const runnerScriptPath = join(tempDir, 'setup runner.cmd')
const startupScriptPath = join(tempDir, 'agent startup.cmd')
const startupLogPath = join(dirname(tempDir), 'agent-started.log')
writeFileSync(runnerScriptPath, '@echo off\r\nexit /b 37\r\n', 'utf8')
writeFileSync(
startupScriptPath,
`@echo off\r\necho started>"${startupLogPath}"\r\nexit /b 0\r\n`,
'utf8'
)
const commands = createSequencedSetupAgentCommands({
runnerScriptPath,
startupCommand: `cmd.exe /d /c "${startupScriptPath}"`,
platform: 'windows',
nonce: 'failed-windows-sequence',
waitTimeoutSeconds: 2
})
const setupExit = await waitForExit(
spawnWindowsCommand(dirname(tempDir), 'run failed setup.cmd', commands.setupCommand)
)
expect(setupExit.code).toBe(37)
expect(readFileSync(`${runnerScriptPath}.failed-windows-sequence.done`, 'utf8')).toBe(
'failed-windows-sequence:37\r\n'
)
const startupExit = await waitForExit(
spawnWindowsCommand(
dirname(tempDir),
'run blocked startup.cmd',
commands.startupCommand,
commands.startupEnv
)
)
expect(startupExit.code).toBe(37)
expect(startupExit.stderr).toContain('Setup failed; skipping agent startup.')
expect(existsSync(startupLogPath)).toBe(false)
})
})
function makeTempDir(directoryName: string): string {
const root = mkdtempSync(join(tmpdir(), 'orca-setup-sequencing-'))
TEMP_DIRS.push(root)
const dir = join(root, directoryName)
mkdirSync(dir)
return dir
}
function spawnWindowsCommand(
dir: string,
filename: string,
command: string,
env: Record<string, string> = {}
): ReturnType<typeof spawn> {
const scriptPath = join(dir, filename)
writeFileSync(scriptPath, `@echo off\r\n${command}\r\nexit /b %ERRORLEVEL%\r\n`, 'utf8')
return spawn('cmd.exe', ['/d', '/c', scriptPath], {
stdio: 'pipe',
env: { ...process.env, ...env }
})
}
function quotePowerShell(value: string): string {
return value.replace(/'/g, "''")
}
function waitForExit(
child: ReturnType<typeof spawn>
): Promise<{ code: number | null; stderr: string }> {
return new Promise((resolve, reject) => {
let stderr = ''
child.stderr?.on('data', (chunk: Buffer | string) => {
stderr += chunk.toString()
})
child.once('error', reject)
child.once('close', (code) => {
resolve({ code, stderr })
})
})
}