diff --git a/src/main/claude-accounts/claude-account-service-login-process.test.ts b/src/main/claude-accounts/claude-account-service-login-process.test.ts index e87981b2956..69bc3793608 100644 --- a/src/main/claude-accounts/claude-account-service-login-process.test.ts +++ b/src/main/claude-accounts/claude-account-service-login-process.test.ts @@ -517,11 +517,14 @@ describe('ClaudeAccountService credential capture', () => { const destroyStdin = vi.spyOn(child.stdin, 'destroy') const taskkill = new EventEmitter() const spawnMock = vi.fn((command: string) => (command === 'taskkill.exe' ? taskkill : child)) + const cleanupInteractiveLogin = vi.fn() const buildInteractiveLoginSpawn = vi.fn(() => ({ command: getCmdExePath(), args: ['/d', '/c', 'start', '', '/wait', 'claude', 'auth', 'login', '--claudeai'], stdio: 'ignore' as const, - windowsHide: true + windowsHide: true, + cleanup: cleanupInteractiveLogin, + getTerminationPid: () => 9876 })) vi.doMock('node:child_process', () => ({ spawn: spawnMock })) vi.doMock('../../shared/windows-interactive-login-spawn', () => ({ @@ -575,13 +578,14 @@ describe('ClaudeAccountService credential capture', () => { expect(child.kill).not.toHaveBeenCalled() expect(spawnMock).toHaveBeenCalledWith( 'taskkill.exe', - ['/pid', '1234', '/t', '/f'], + ['/pid', '9876', '/t', '/f'], expect.objectContaining({ stdio: 'ignore', windowsHide: true }) ) expect(destroyStdin).not.toHaveBeenCalled() taskkill.emit('close', 0) await rejection expect(destroyStdin).toHaveBeenCalledTimes(1) + expect(cleanupInteractiveLogin).toHaveBeenCalledTimes(1) expect(service.cancelPendingLogin()).toBe(false) } finally { vi.doUnmock('node:child_process') diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 25ea4da5724..72aea7a04c7 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -1156,6 +1156,7 @@ export class ClaudeAccountService { child.off('error', onError) child.off(completionEvent, onDone) options?.signal?.removeEventListener('abort', onAbort) + interactiveLogin?.cleanup?.() if (options?.keepStdinOpen) { child.stdin?.destroy() } @@ -1180,11 +1181,16 @@ export class ClaudeAccountService { return } terminationPending = true - if (process.platform === 'win32' && child.pid) { - const taskkill = spawn('taskkill.exe', ['/pid', String(child.pid), '/t', '/f'], { - stdio: 'ignore', - windowsHide: true - }) + const windowsTerminationPid = interactiveLogin?.getTerminationPid?.() ?? child.pid + if (process.platform === 'win32' && windowsTerminationPid) { + const taskkill = spawn( + 'taskkill.exe', + ['/pid', String(windowsTerminationPid), '/t', '/f'], + { + stdio: 'ignore', + windowsHide: true + } + ) let taskkillFinished = false const finishTaskkill = (succeeded: boolean): void => { if (taskkillFinished) { diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index d70d2c6185f..2a4ef384f80 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -6,7 +6,10 @@ import { dirname, join, resolve, sep } from 'node:path' import { homedir } from 'node:os' import { app } from 'electron' import { getSpawnArgsForWindows } from '../win32-utils' -import { buildWindowsHostInteractiveLoginSpawn } from '../../shared/windows-interactive-login-spawn' +import { + buildWindowsHostInteractiveLoginSpawn, + type WindowsHostInteractiveLoginSpawn +} from '../../shared/windows-interactive-login-spawn' import type { CodexManagedAccount, CodexManagedAccountSummary, @@ -212,10 +215,14 @@ function removeManagedHomeTreeSync(targetPath: string): void { }) } -function killLoginProcessTree(child: ChildProcess): void { +function killLoginProcessTree( + child: ChildProcess, + interactiveLogin?: WindowsHostInteractiveLoginSpawn | null +): void { + const terminationPid = interactiveLogin?.getTerminationPid?.() ?? child.pid if ( process.platform === 'win32' && - typeof child.pid === 'number' && + typeof terminationPid === 'number' && child.exitCode === null && child.signalCode === null ) { @@ -223,7 +230,7 @@ function killLoginProcessTree(child: ChildProcess): void { // Why: child.kill() only reaches the direct child (cmd.exe for npm .cmd // shims); taskkill /t also ends codex descendants whose open handles on // the managed home make post-login file operations fail with ENOTEMPTY. - execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + execFileSync('taskkill', ['/pid', String(terminationPid), '/t', '/f'], { windowsHide: true, timeout: WINDOWS_LOGIN_TREE_KILL_TIMEOUT_MS, stdio: 'ignore' @@ -1777,6 +1784,7 @@ export class CodexAccountService { child.stderr?.off('data', appendOutput) child.off('error', onError) child.off('close', onClose) + spawnConfig.interactiveLogin?.cleanup?.() } const settle = (callback: () => void): void => { @@ -1790,7 +1798,7 @@ export class CodexAccountService { const timeoutError = new Error('Codex sign-in took too long to finish. Please try again.') timeout = setTimeout(() => { - killLoginProcessTree(child) + killLoginProcessTree(child, spawnConfig.interactiveLogin) settle(() => { rejectPromise(timeoutError) }) @@ -1811,7 +1819,7 @@ export class CodexAccountService { } postAuthExitTimeout = setTimeout(() => { loginTreeKilledAfterAuth = true - killLoginProcessTree(child) + killLoginProcessTree(child, spawnConfig.interactiveLogin) }, WINDOWS_LOGIN_POST_AUTH_EXIT_GRACE_MS) }, WINDOWS_LOGIN_AUTH_POLL_INTERVAL_MS) } diff --git a/src/shared/windows-interactive-login-spawn.test.ts b/src/shared/windows-interactive-login-spawn.test.ts index 3573d107760..a89cfbd8e58 100644 --- a/src/shared/windows-interactive-login-spawn.test.ts +++ b/src/shared/windows-interactive-login-spawn.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { existsSync, writeFileSync } from 'node:fs' import { getCmdExePath } from './windows-batch-spawn' import { buildWindowsHostInteractiveLoginSpawn } from './windows-interactive-login-spawn' @@ -12,8 +13,12 @@ function withWindows(fn: () => T): T { } } +function encodedValue(value: string): string { + return `Read-OrcaValue '${Buffer.from(value).toString('base64')}'` +} + describe('buildWindowsHostInteractiveLoginSpawn', () => { - it('launches a batch login in a visible console and waits for it', () => { + it('relays a batch login through a visible, PID-addressable console', () => { const spawn = withWindows(() => buildWindowsHostInteractiveLoginSpawn('C:\\Tools\\claude.cmd', [ 'auth', @@ -22,31 +27,43 @@ describe('buildWindowsHostInteractiveLoginSpawn', () => { ]) ) expect(spawn.command).toBe(getCmdExePath()) - expect(spawn.args).toEqual([ - '/d', - '/c', - 'start', - '', - '/wait', - getCmdExePath(), - '/d', - '/c', - 'C:\\Tools\\claude.cmd', - 'auth', - 'login', - '--claudeai' + expect(spawn.args.slice(0, 5)).toEqual(['/d', '/c', 'start', '', '/wait']) + expect(spawn.args[5]).toMatch(/WindowsPowerShell\\v1\.0\\powershell\.exe$/i) + expect(spawn.args.slice(6, 11)).toEqual([ + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-EncodedCommand' ]) + + const script = Buffer.from(spawn.args[11] ?? '', 'base64').toString('utf16le') + expect(script).toContain('[string]$PID') + expect(script).toContain(encodedValue(getCmdExePath())) + expect(script).toContain(encodedValue('C:\\Tools\\claude.cmd')) + expect(script).toContain(encodedValue('--claudeai')) + const pidPathBase64 = script.match( + /WriteAllText\(\(Read-OrcaValue '([^']+)'\), \[string\]\$PID\)/ + )?.[1] + expect(pidPathBase64).toBeTruthy() + const pidFilePath = Buffer.from(pidPathBase64!, 'base64').toString('utf8') + expect(spawn.stdio).toBe('ignore') expect(spawn.windowsHide).toBe(true) + expect(spawn.getTerminationPid()).toBeNull() + writeFileSync(pidFilePath, '2468') + expect(spawn.getTerminationPid()).toBe(2468) + spawn.cleanup() + expect(existsSync(pidFilePath)).toBe(false) }) it('routes executable logins through the same waiting console boundary', () => { - const spawn = buildWindowsHostInteractiveLoginSpawn('C:\\Tools\\codex.exe', ['login']) - expect(spawn).toEqual({ - command: getCmdExePath(), - args: ['/d', '/c', 'start', '', '/wait', 'C:\\Tools\\codex.exe', 'login'], - stdio: 'ignore', - windowsHide: true - }) + const spawn = withWindows(() => + buildWindowsHostInteractiveLoginSpawn('C:\\Tools\\codex.exe', ['login']) + ) + const script = Buffer.from(spawn.args[11] ?? '', 'base64').toString('utf16le') + expect(script).toContain(encodedValue('C:\\Tools\\codex.exe')) + expect(script).toContain(encodedValue('login')) + spawn.cleanup() }) }) diff --git a/src/shared/windows-interactive-login-spawn.ts b/src/shared/windows-interactive-login-spawn.ts index 63612b98c57..b2e3f19d7ff 100644 --- a/src/shared/windows-interactive-login-spawn.ts +++ b/src/shared/windows-interactive-login-spawn.ts @@ -1,3 +1,7 @@ +import { randomUUID } from 'node:crypto' +import { readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, win32 } from 'node:path' import { getSpawnArgsForWindows, wrapWindowsStartWait } from './windows-batch-spawn' export type WindowsHostInteractiveLoginSpawn = { @@ -5,6 +9,36 @@ export type WindowsHostInteractiveLoginSpawn = { args: string[] stdio: 'ignore' windowsHide: boolean + cleanup: () => void + getTerminationPid: () => number | null +} + +function encodeUtf8(value: string): string { + return Buffer.from(value, 'utf8').toString('base64') +} + +function buildPidRelayScript(command: string, args: string[], pidFilePath: string): string { + const decode = + 'function Read-OrcaValue([string]$Value) { [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value)) }' + const encodedArgs = args.map((arg) => `(Read-OrcaValue '${encodeUtf8(arg)}')`).join(',') + return [ + decode, + `$Command = Read-OrcaValue '${encodeUtf8(command)}'`, + `$Arguments = @(${encodedArgs})`, + `[IO.File]::WriteAllText((Read-OrcaValue '${encodeUtf8(pidFilePath)}'), [string]$PID)`, + '& $Command @Arguments', + 'if ($null -eq $LASTEXITCODE) { exit 0 }', + 'exit $LASTEXITCODE' + ].join('; ') +} + +function readPidFile(pidFilePath: string): number | null { + try { + const pid = Number.parseInt(readFileSync(pidFilePath, 'utf8').trim(), 10) + return Number.isSafeInteger(pid) && pid > 0 ? pid : null + } catch { + return null + } } export function buildWindowsHostInteractiveLoginSpawn( @@ -12,11 +46,29 @@ export function buildWindowsHostInteractiveLoginSpawn( args: string[] ): WindowsHostInteractiveLoginSpawn { const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, args) - const wrapped = wrapWindowsStartWait(spawnCmd, spawnArgs) + const pidFilePath = join(tmpdir(), `orca-interactive-login-${randomUUID()}.pid`) + const powershell = win32.join( + process.env.SystemRoot ?? 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' + ) + const script = buildPidRelayScript(spawnCmd, spawnArgs, pidFilePath) + const wrapped = wrapWindowsStartWait(powershell, [ + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-EncodedCommand', + Buffer.from(script, 'utf16le').toString('base64') + ]) return { command: wrapped.spawnCmd, args: wrapped.spawnArgs, stdio: 'ignore', - windowsHide: true + windowsHide: true, + cleanup: () => rmSync(pidFilePath, { force: true }), + getTerminationPid: () => readPidFile(pidFilePath) } }