diff --git a/config/scripts/claude-account-windows-spawn-repro.mjs b/config/scripts/claude-account-windows-spawn-repro.mjs new file mode 100644 index 000000000..eaf04762e --- /dev/null +++ b/config/scripts/claude-account-windows-spawn-repro.mjs @@ -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 ' + ) +} +if (process.platform !== 'win32') { + throw new Error('This reproduction requires a physical Windows host.') +} + +const expectedArgs = [ + '', + 'two words', + 'amp&ersand', + 'pipe|value', + 'lessvalue', + '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: 'lessvalue', + 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 diff --git a/src/main/claude-accounts/service.test.ts b/src/main/claude-accounts/service.test.ts index 1c0f94e17..dfbcd2322 100644 --- a/src/main/claude-accounts/service.test.ts +++ b/src/main/claude-accounts/service.test.ts @@ -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 + } + 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 + } + ).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 + } + 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 + } + ).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 - } - 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 { diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 510b9a5fe..1b0485997 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -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 | 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) diff --git a/src/main/claude-accounts/windows-command-invocation.test.ts b/src/main/claude-accounts/windows-command-invocation.test.ts new file mode 100644 index 000000000..ed3682ad0 --- /dev/null +++ b/src/main/claude-accounts/windows-command-invocation.test.ts @@ -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' + ) + }) +}) diff --git a/src/main/claude-accounts/windows-command-invocation.ts b/src/main/claude-accounts/windows-command-invocation.ts new file mode 100644 index 000000000..440c665a5 --- /dev/null +++ b/src/main/claude-accounts/windows-command-invocation.ts @@ -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 + } +}