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:
parent
43f626574b
commit
87c59dd27d
|
|
@ -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
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue