mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 16:03:45 +00:00
fix(windows): terminate fallback login consoles
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user