fix(windows): terminate fallback login consoles

This commit is contained in:
Neil
2026-08-23 14:24:14 -07:00
parent 3af0255fe4
commit d1985c86fc
5 changed files with 123 additions and 36 deletions
@@ -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')
+11 -5
View File
@@ -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) {
+14 -6
View File
@@ -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)
}
@@ -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<T>(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()
})
})
+54 -2
View File
@@ -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)
}
}