fix(claude-accounts): quote resolved claude path for Windows shell spawn (#10237)

* fix(claude-accounts): quote resolved claude path for Windows shell spawn

runClaudeCommand spawns the resolved claude command with shell:true on
Windows, but spawn concatenates the command into the cmd.exe line without
quoting. When the CLI resolves to a path containing spaces (e.g.
C:\Users\First Last\AppData\Roaming\npm\claude.cmd), cmd.exe splits at the
first space and account add fails with:

  'C:\Users\First' is not recognized as an internal or external command

Quote the command the same way claude-pty.ts and quoteWindowsCmdArg
already do for other Windows spawns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(claude-accounts): own Windows cmd invocation

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
ghoon4 2026-07-24 09:44:43 +09:00 committed by GitHub
parent 43f626574b
commit 87c59dd27d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 599 additions and 31 deletions

View File

@ -0,0 +1,342 @@
import { spawn } from 'node:child_process'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { buildWindowsCommandInvocation } from '../../src/main/claude-accounts/windows-command-invocation.ts'
const strategy = process.argv[2]
if (!['baseline', 'candidate', 'explicit-cmd'].includes(strategy)) {
throw new Error(
'Usage: node config/scripts/claude-account-windows-spawn-repro.mjs <baseline|candidate|explicit-cmd>'
)
}
if (process.platform !== 'win32') {
throw new Error('This reproduction requires a physical Windows host.')
}
const expectedArgs = [
'',
'two words',
'amp&ersand',
'pipe|value',
'less<value',
'greater>value',
'caret^value',
'trailing\\',
'two-trailing\\\\',
'(parentheses)',
'100%',
'%ORCA_ARG_TRAP%',
'bang!value',
'한글-λ'
]
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-claude-spawn-'))
const reportedDir = join(tempRoot, 'Profile with spaces 한글')
const reportedCapturePath = join(reportedDir, 'capture.json')
const reportedPidPath = join(reportedDir, 'pids.json')
const reportedShimPath = join(reportedDir, 'claude fixture.cmd')
const reportedFixturePath = join(reportedDir, 'capture-child.cjs')
const fixtureDir = join(tempRoot, 'Profile space & ^ (paren) %ORCA_PATH_TRAP% !bang! 한글')
const capturePath = join(fixtureDir, 'capture.json')
const pidPath = join(fixtureDir, 'pids.json')
const shimPath = join(fixtureDir, 'claude fixture.cmd')
const fixturePath = join(fixtureDir, 'capture-child.cjs')
const fixtureEnv = {
...process.env,
CLAUDE_CONFIG_DIR: join(fixtureDir, 'config space & ^ (paren) %ORCA_ENV_LITERAL% !bang! 한글'),
ORCA_ARG_TRAP: 'EXPANDED_ARG',
ORCA_PATH_TRAP: 'EXPANDED_PATH',
ORCA_FIXTURE_CAPTURE: capturePath,
ORCA_FIXTURE_PIDS: pidPath,
ORCA_FIXTURE_NODE: process.execPath
}
const reportedEnv = {
...fixtureEnv,
CLAUDE_CONFIG_DIR: join(reportedDir, 'config with spaces 한글'),
ORCA_FIXTURE_CAPTURE: reportedCapturePath,
ORCA_FIXTURE_PIDS: reportedPidPath
}
function quoteForCandidate(value) {
return `"${value.replace(/"/g, '""')}"`
}
function launch(args, command = shimPath, env = fixtureEnv) {
if (strategy === 'baseline') {
return spawn(command, args, { cwd: tempRoot, env, shell: true, windowsHide: true })
}
if (strategy === 'candidate') {
return spawn(quoteForCandidate(command), args, {
cwd: tempRoot,
env,
shell: true,
windowsHide: true
})
}
const invocation = buildWindowsCommandInvocation(command, args)
return spawn(invocation.command, invocation.args, {
cwd: tempRoot,
env,
shell: false,
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
windowsHide: true
})
}
function collect(child) {
return new Promise((resolve) => {
let stdout = ''
let stderr = ''
child.stdout?.on('data', (chunk) => (stdout += chunk.toString()))
child.stderr?.on('data', (chunk) => (stderr += chunk.toString()))
child.on('error', (error) => resolve({ code: null, stdout, stderr, error: error.message }))
child.on('close', (code) => resolve({ code, stdout, stderr, error: null }))
})
}
async function waitForFile(path, timeoutMs = 5_000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
return JSON.parse(await readFile(path, 'utf8'))
} catch {
await new Promise((resolve) => setTimeout(resolve, 25))
}
}
throw new Error(`Timed out waiting for fixture output: ${path}`)
}
async function taskExists(pid) {
const result = await collect(
spawn('tasklist.exe', ['/fi', `PID eq ${pid}`, '/fo', 'csv', '/nh'], {
windowsHide: true
})
)
if (result.error || result.code !== 0) {
throw new Error(`tasklist failed for PID ${pid}: ${result.error ?? result.stderr}`)
}
return result.stdout.includes(`"${pid}"`)
}
async function killTree(pid) {
const result = await collect(
spawn('taskkill.exe', ['/pid', String(pid), '/t', '/f'], { windowsHide: true })
)
if (result.error || result.code !== 0) {
throw new Error(`taskkill failed for PID ${pid}: ${result.error ?? result.stderr}`)
}
}
async function waitForTreeExit(pids, timeoutMs = 5_000) {
const deadline = Date.now() + timeoutMs
let alive = {}
do {
alive = Object.fromEntries(
await Promise.all(
Object.entries(pids).map(async ([name, pid]) => [name, await taskExists(pid)])
)
)
if (!Object.values(alive).some(Boolean)) {
return alive
}
await new Promise((resolve) => setTimeout(resolve, 50))
} while (Date.now() < deadline)
return alive
}
const results = {
strategy,
reportedPath: null,
pathMatrix: {},
argvMatrix: {},
hostilePathAndArgv: null,
error: null,
cancellation: null
}
const fixtureSource =
`const { spawn } = require('node:child_process')\n` +
`const { writeFileSync } = require('node:fs')\n` +
`if (process.argv[2] === '--exit-error') { process.stderr.write('fixture error: 한글 & ^ % !\\n'); process.exit(23) }\n` +
`if (process.argv[2] === '--linger') {\n` +
` const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { windowsHide: true })\n` +
` writeFileSync(process.env.ORCA_FIXTURE_PIDS, JSON.stringify({ child: process.pid, grandchild: grandchild.pid }))\n` +
` setInterval(() => {}, 1000)\n` +
`} else {\n` +
` writeFileSync(process.env.ORCA_FIXTURE_CAPTURE, JSON.stringify({ argv: process.argv.slice(2), configDir: process.env.CLAUDE_CONFIG_DIR }))\n` +
`}\n`
const shimSource = '@echo off\r\n"%ORCA_FIXTURE_NODE%" "%~dp0capture-child.cjs" %*\r\n'
let lingeringShellPid = null
try {
await mkdir(fixtureDir, { recursive: true })
await mkdir(reportedDir, { recursive: true })
await writeFile(fixturePath, fixtureSource, 'utf8')
await writeFile(shimPath, shimSource, 'utf8')
await writeFile(reportedFixturePath, fixtureSource, 'utf8')
await writeFile(reportedShimPath, shimSource, 'utf8')
const reportedArgs = ['auth', 'status', '--json']
const reportedRun = await collect(launch(reportedArgs, reportedShimPath, reportedEnv))
let reportedCapture = null
try {
reportedCapture = await waitForFile(reportedCapturePath, 1_000)
} catch {}
results.reportedPath = {
...reportedRun,
actual: reportedCapture,
expected: { argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR },
pass:
reportedRun.code === 0 &&
JSON.stringify(reportedCapture) ===
JSON.stringify({ argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR })
}
for (const [name, segment] of Object.entries({
spaces: 'profile space',
ampersand: 'profile&name',
caret: 'profile^name',
parentheses: 'profile(name)',
percent: 'profile%ORCA_PATH_TRAP%',
bang: 'profile!name',
unicode: 'profile-한글-λ'
})) {
const directory = join(tempRoot, segment)
const captureFile = join(directory, 'capture.json')
const command = join(directory, 'claude fixture.cmd')
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'capture-child.cjs'), fixtureSource, 'utf8')
await writeFile(command, shimSource, 'utf8')
const env = {
...fixtureEnv,
CLAUDE_CONFIG_DIR: join(directory, 'config'),
ORCA_FIXTURE_CAPTURE: captureFile
}
const run = await collect(launch(reportedArgs, command, env))
let actual = null
try {
actual = await waitForFile(captureFile, 500)
} catch {}
results.pathMatrix[name] = {
code: run.code,
stderr: run.stderr.trim(),
actual: actual?.argv ?? null,
pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(reportedArgs)
}
}
for (const [name, value] of Object.entries({
empty: '',
spaces: 'two words',
ampersand: 'amp&ersand',
pipe: 'pipe|value',
lessThan: 'less<value',
greaterThan: 'greater>value',
caret: 'caret^value',
trailingBackslash: 'trailing\\',
twoTrailingBackslashes: 'two-trailing\\\\',
parentheses: '(parentheses)',
percent: '%ORCA_ARG_TRAP%',
bang: 'bang!value',
unicode: '한글-λ'
})) {
const args = ['prefix', value, 'suffix']
const captureFile = join(reportedDir, `capture-${name}.json`)
const env = { ...reportedEnv, ORCA_FIXTURE_CAPTURE: captureFile }
const run = await collect(launch(args, reportedShimPath, env))
let actual = null
try {
actual = await waitForFile(captureFile, 500)
} catch {}
results.argvMatrix[name] = {
code: run.code,
stderr: run.stderr.trim(),
actual: actual?.argv ?? null,
pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(args)
}
}
const argvRun = await collect(launch(expectedArgs))
let capture = null
try {
capture = await waitForFile(capturePath, 1_000)
} catch {}
results.hostilePathAndArgv = {
...argvRun,
actual: capture,
expected: { argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR },
pass:
argvRun.code === 0 &&
JSON.stringify(capture) ===
JSON.stringify({ argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR })
}
results.error = await collect(launch(['--exit-error'], reportedShimPath, reportedEnv))
results.error.pass =
results.error.code === 23 && results.error.stderr.includes('fixture error: 한글 & ^ % !')
const lingering = launch(['--linger'], reportedShimPath, reportedEnv)
lingeringShellPid = lingering.pid
const lingeringResult = collect(lingering)
try {
const pids = await waitForFile(reportedPidPath)
await killTree(lingering.pid)
const alive = await waitForTreeExit({
shell: lingering.pid,
child: pids.child,
grandchild: pids.grandchild
})
results.cancellation = {
shell: lingering.pid,
...pids,
alive,
pass: !Object.values(alive).some(Boolean)
}
} catch (error) {
if (await taskExists(lingering.pid)) {
await killTree(lingering.pid)
}
const launchResult = await Promise.race([
lingeringResult,
new Promise((resolve) =>
setTimeout(
() => resolve({ code: null, error: 'fixture did not exit after cleanup' }),
5_000
)
)
])
results.cancellation = {
shell: lingering.pid,
launchResult,
pass: false,
error: error instanceof Error ? error.message : String(error)
}
}
} finally {
if (lingeringShellPid && (await taskExists(lingeringShellPid))) {
await killTree(lingeringShellPid)
}
try {
let pids
try {
pids = JSON.parse(await readFile(reportedPidPath, 'utf8'))
} catch {
pids = JSON.parse(await readFile(pidPath, 'utf8'))
}
for (const pid of [pids.child, pids.grandchild]) {
if (await taskExists(pid)) {
await killTree(pid)
}
}
} catch {}
await rm(tempRoot, { recursive: true, force: true })
}
console.log(JSON.stringify(results, null, 2))
process.exitCode =
results.reportedPath?.pass &&
Object.values(results.pathMatrix).every((result) => result.pass) &&
Object.values(results.argvMatrix).every((result) => result.pass) &&
results.hostilePathAndArgv?.pass &&
results.error?.pass &&
results.cancellation?.pass
? 0
: 1

View File

@ -21,8 +21,12 @@ vi.mock('electron', () => ({
}
}))
const commandMocks = vi.hoisted(() => ({
resolveClaudeCommand: vi.fn(() => 'claude')
}))
vi.mock('../codex-cli/command', () => ({
resolveClaudeCommand: () => 'claude'
resolveClaudeCommand: commandMocks.resolveClaudeCommand
}))
vi.mock('./keychain', () => ({
@ -1260,6 +1264,126 @@ describe('ClaudeAccountService credential capture', () => {
}
})
it('owns the complete cmd.exe command line for a resolved Windows Claude command', async () => {
setPlatform('win32')
vi.resetModules()
commandMocks.resolveClaudeCommand.mockReturnValueOnce(
'C:\\Users\\First Last\\AppData\\Roaming\\npm\\claude.cmd'
)
const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough
stderr: PassThrough
kill: ReturnType<typeof vi.fn>
}
child.stdout = new PassThrough()
child.stderr = new PassThrough()
child.kill = vi.fn()
const spawnMock = vi.fn(() => {
child.stdout.write('{"email":"user@example.com"}\n')
queueMicrotask(() => child.emit('close', 0))
return child
})
vi.doMock('node:child_process', () => ({ spawn: spawnMock }))
try {
const { ClaudeAccountService } = await import('./service')
const service = new ClaudeAccountService(
createService() as never,
createService() as never,
createService() as never
)
await (
service as unknown as {
runClaudeCommand(
args: string[],
configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null },
timeoutMs: number
): Promise<string>
}
).runClaudeCommand(
['auth', 'status', '--json'],
{ windowsPath: 'C:\\tmp\\claude-auth', linuxPath: null, wslDistro: null },
1000
)
expect(spawnMock).toHaveBeenCalledWith(
process.env.ComSpec ?? 'cmd.exe',
[
'/d',
'/v:off',
'/s',
'/c',
'""C:\\Users\\First Last\\AppData\\Roaming\\npm\\claude.cmd" "auth" "status" "--json""'
],
expect.objectContaining({ shell: false, windowsVerbatimArguments: true })
)
} finally {
vi.doUnmock('node:child_process')
}
})
it('keeps WSL execution separate from Windows command resolution', async () => {
setPlatform('win32')
vi.resetModules()
commandMocks.resolveClaudeCommand.mockClear()
const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough
stderr: PassThrough
kill: ReturnType<typeof vi.fn>
}
child.stdout = new PassThrough()
child.stderr = new PassThrough()
child.kill = vi.fn()
const spawnMock = vi.fn(() => {
child.stdout.write('{"email":"user@example.com"}\n')
queueMicrotask(() => child.emit('close', 0))
return child
})
vi.doMock('node:child_process', () => ({ spawn: spawnMock }))
try {
const { ClaudeAccountService } = await import('./service')
const service = new ClaudeAccountService(
createService() as never,
createService() as never,
createService() as never
)
await (
service as unknown as {
runClaudeCommand(
args: string[],
configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null },
timeoutMs: number
): Promise<string>
}
).runClaudeCommand(
['auth', 'status', '--json'],
{
windowsPath: 'C:\\tmp\\claude-auth',
linuxPath: '/home/user/.config/orca auth',
wslDistro: 'Ubuntu Test'
},
1000
)
expect(commandMocks.resolveClaudeCommand).not.toHaveBeenCalled()
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
[
'-d',
'Ubuntu Test',
'--',
'bash',
'-lc',
"export CLAUDE_CONFIG_DIR='/home/user/.config/orca auth'; exec claude 'auth' 'status' '--json'"
],
expect.objectContaining({ shell: false, windowsVerbatimArguments: false })
)
} finally {
vi.doUnmock('node:child_process')
}
})
it('pipes stdin only for the explicit Claude account login command', async () => {
setPlatform('linux')
vi.resetModules()
@ -1547,10 +1671,7 @@ describe('ClaudeAccountService credential capture', () => {
child.stderr = new PassThrough()
child.kill = vi.fn()
const destroyStdin = vi.spyOn(child.stdin, 'destroy')
const taskkill = new EventEmitter() as EventEmitter & {
unref: ReturnType<typeof vi.fn>
}
taskkill.unref = vi.fn()
const taskkill = new EventEmitter()
const spawnMock = vi.fn((command: string) => (command === 'taskkill.exe' ? taskkill : child))
vi.doMock('node:child_process', () => ({ spawn: spawnMock }))
@ -1585,21 +1706,23 @@ describe('ClaudeAccountService credential capture', () => {
const addPromise = service.addAccount()
await vi.waitFor(() => {
expect(spawnMock).toHaveBeenCalledWith(
'claude',
['auth', 'login', '--claudeai'],
expect.objectContaining({ shell: true })
process.env.ComSpec ?? 'cmd.exe',
['/d', '/v:off', '/s', '/c', '""claude" "auth" "login" "--claudeai""'],
expect.objectContaining({ shell: false, windowsVerbatimArguments: true })
)
})
expect(service.cancelPendingLogin()).toBe(true)
await expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.')
const rejection = expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.')
expect(child.kill).not.toHaveBeenCalled()
expect(spawnMock).toHaveBeenCalledWith(
'taskkill.exe',
['/pid', '1234', '/t', '/f'],
expect.objectContaining({ stdio: 'ignore', windowsHide: true })
)
expect(taskkill.unref).toHaveBeenCalled()
expect(destroyStdin).not.toHaveBeenCalled()
taskkill.emit('close', 0)
await rejection
expect(destroyStdin).toHaveBeenCalledTimes(1)
expect(service.cancelPendingLogin()).toBe(false)
} finally {

View File

@ -35,6 +35,7 @@ import { findDuplicateClaudeAccount } from './claude-duplicate-account'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { toWindowsWslPath } from '../wsl'
import { buildEncodedWslBashCommand } from '../wsl-bash-command'
import { buildWindowsCommandInvocation } from './windows-command-invocation'
import {
getClaudeSelectionTargetForAccount,
getSelectedClaudeAccountIdForTarget,
@ -49,6 +50,7 @@ import {
const LOGIN_TIMEOUT_MS = 180_000
const STATUS_TIMEOUT_MS = 20_000
const MAX_COMMAND_OUTPUT_CHARS = 4_000
const WINDOWS_TASKKILL_TIMEOUT_MS = 5_000
// Claude leaves the login process running after an OAuth denial; fail fast so Settings can clear loading state.
const CLAUDE_AUTH_DENIED_PATTERN =
/\baccess_denied\b|authorization (?:request )?(?:was )?denied|sign-?in (?:was )?denied|login (?:was )?denied/i
@ -922,23 +924,35 @@ export class ClaudeAccountService {
`export CLAUDE_CONFIG_DIR=${shellQuote(configDir.linuxPath)}; exec claude ${args.map(shellQuote).join(' ')}`
],
env: process.env,
shell: false
}
: {
command: resolveClaudeCommand(),
args,
env: {
...process.env,
CLAUDE_CONFIG_DIR: configDir.windowsPath
},
shell: process.platform === 'win32'
shell: false,
windowsVerbatimArguments: false
}
: process.platform === 'win32'
? {
...buildWindowsCommandInvocation(resolveClaudeCommand(), args),
env: {
...process.env,
CLAUDE_CONFIG_DIR: configDir.windowsPath
},
shell: false
}
: {
command: resolveClaudeCommand(),
args,
env: {
...process.env,
CLAUDE_CONFIG_DIR: configDir.windowsPath
},
shell: false,
windowsVerbatimArguments: false
}
const child = spawn(spawnConfig.command, spawnConfig.args, {
// Why: Claude's browser auth can bind its callback lifetime to stdin.
// Keeping stdin open prevents hidden managed-login runs from tearing down
// the local callback server before the browser returns.
stdio: [options?.keepStdinOpen ? 'pipe' : 'ignore', 'pipe', 'pipe'],
shell: spawnConfig.shell,
windowsVerbatimArguments: spawnConfig.windowsVerbatimArguments,
env: spawnConfig.env,
// Why: Claude auth can leave browser/login descendants alive after denial.
// A process group lets cancellation terminate the whole POSIX login tree.
@ -963,10 +977,9 @@ export class ClaudeAccountService {
output = output.slice(-MAX_COMMAND_OUTPUT_CHARS)
}
if (CLAUDE_AUTH_DENIED_PATTERN.test(output)) {
// Use killChild (not child.kill) so the whole login/browser tree is torn down on
// Windows (taskkill /t) and the detached POSIX group, matching the timeout/abort paths.
killChild()
settle(() => rejectPromise(new Error('Claude sign-in was denied. Please try again.')))
killChild(() =>
settle(() => rejectPromise(new Error('Claude sign-in was denied. Please try again.')))
)
}
}
let timeout: ReturnType<typeof setTimeout> | null = null
@ -994,39 +1007,66 @@ export class ClaudeAccountService {
}
const timeoutError = new Error('Claude sign-in took too long to finish.')
const cancelError = new Error('Claude sign-in was cancelled.')
const killChild = (): void => {
let terminationPending = false
const killChild = (afterKill: () => void): void => {
if (terminationPending || settled) {
return
}
terminationPending = true
if (process.platform === 'win32' && child.pid) {
const taskkill = spawn('taskkill.exe', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true
})
taskkill.on('error', () => {})
taskkill.unref()
let taskkillFinished = false
const finishTaskkill = (succeeded: boolean): void => {
if (taskkillFinished) {
return
}
taskkillFinished = true
clearTimeout(taskkillTimeout)
if (!succeeded) {
child.kill()
}
afterKill()
}
const taskkillTimeout = setTimeout(() => {
taskkill.kill()
finishTaskkill(false)
}, WINDOWS_TASKKILL_TIMEOUT_MS)
taskkill.once('error', () => finishTaskkill(false))
taskkill.once('close', (code) => finishTaskkill(code === 0))
return
}
if (process.platform !== 'win32' && child.pid) {
try {
process.kill(-child.pid)
afterKill()
return
} catch {
// Fall back to the direct child if the process group is unavailable.
}
}
child.kill()
afterKill()
}
timeout = setTimeout(() => {
killChild()
settle(() => rejectPromise(timeoutError))
killChild(() => settle(() => rejectPromise(timeoutError)))
}, timeoutMs)
const onAbort = (): void => {
killChild()
settle(() => rejectPromise(cancelError))
killChild(() => settle(() => rejectPromise(cancelError)))
}
const onError = (error: Error): void => {
if (terminationPending) {
return
}
settle(() => rejectPromise(error))
}
const onClose = (code: number | null): void => {
if (terminationPending) {
return
}
settle(() => {
if (code === 0 || options?.allowFailure) {
resolvePromise(output)

View File

@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { buildWindowsCommandInvocation } from './windows-command-invocation'
describe('buildWindowsCommandInvocation', () => {
it('preserves hostile-but-valid cmd path and argument characters', () => {
const invocation = buildWindowsCommandInvocation(
'C:\\Users\\space & ^ (paren) %PATH_TRAP% !bang! 한글\\claude.cmd',
['', 'two words', 'amp&ersand', 'caret^value', '(parentheses)', '%ARG_TRAP%', '한글-λ'],
'C:\\Windows\\System32\\cmd.exe'
)
expect(invocation).toEqual({
command: 'C:\\Windows\\System32\\cmd.exe',
args: [
'/d',
'/v:off',
'/s',
'/c',
'""C:\\Users\\space & ^ (paren) "^%"PATH_TRAP"^%" !bang! 한글\\claude.cmd" "" "two words" "amp&ersand" "caret^value" "(parentheses)" ""^%"ARG_TRAP"^%"" "한글-λ""'
],
windowsVerbatimArguments: true
})
})
it('rejects tokens that cmd.exe cannot preserve safely', () => {
expect(() => buildWindowsCommandInvocation('claude.cmd', ['line\nbreak'])).toThrow(
'cannot contain quotes or line breaks'
)
expect(() => buildWindowsCommandInvocation('claude.cmd', ['quoted"value'])).toThrow(
'cannot contain quotes or line breaks'
)
})
})

View File

@ -0,0 +1,30 @@
export type WindowsCommandInvocation = {
command: string
args: string[]
windowsVerbatimArguments: true
}
function quoteCmdToken(value: string): string {
if (/[\r\n"]/.test(value)) {
throw new Error('Windows command tokens cannot contain quotes or line breaks.')
}
const crtEscaped = value.replace(
/(\\*)$/,
(_match, backslashes: string) => `${backslashes}${backslashes}`
)
// Percent expansion still runs inside quotes, so briefly leave the quoted span to escape it.
return `"${crtEscaped.replace(/%/g, '"^%"')}"`
}
export function buildWindowsCommandInvocation(
command: string,
args: string[],
commandInterpreter = process.env.ComSpec ?? 'cmd.exe'
): WindowsCommandInvocation {
const commandLine = [command, ...args].map(quoteCmdToken).join(' ')
return {
command: commandInterpreter,
args: ['/d', '/v:off', '/s', '/c', `"${commandLine}"`],
windowsVerbatimArguments: true
}
}