fix(windows): attach interactive login children to the console input device

GUI-subsystem orca.exe inherits stdio that cannot satisfy ReadConsole, so
claude/codex login never consumes pasted codes. Open CONIN$/CONOUT$ for CLI
children, and give Windows-host GUI logins a dedicated console instead of
hidden pipes.

Fixes #14932
This commit is contained in:
Brennan Benson
2026-08-23 14:01:42 -07:00
committed by Neil
parent 7a72f341f7
commit 04fae9c884
15 changed files with 573 additions and 26 deletions
+55
View File
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
deleteKeychainMock,
getVersionManagerBinPathsMock,
openWindowsInteractiveStdioMock,
readKeychainMock,
resolveCliCommandMock,
rmSyncMock,
@@ -16,6 +17,9 @@ const {
} = vi.hoisted(() => ({
deleteKeychainMock: vi.fn(),
getVersionManagerBinPathsMock: vi.fn(),
openWindowsInteractiveStdioMock: vi.fn(
(): { stdio: [number, number, number]; dispose: () => void } | 'inherit' => 'inherit'
),
readKeychainMock: vi.fn(),
resolveCliCommandMock: vi.fn(),
rmSyncMock: vi.fn(),
@@ -45,6 +49,22 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({
getVersionManagerBinPaths: getVersionManagerBinPathsMock,
resolveCliCommand: resolveCliCommandMock
}))
vi.mock('../../shared/windows-console-stdio', () => ({
openWindowsInteractiveStdio: openWindowsInteractiveStdioMock,
stdioForWindowsInteractiveChild: (json: boolean) => {
const opened = openWindowsInteractiveStdioMock()
if (opened === 'inherit') {
return {
stdio: ['inherit', json ? process.stderr : 'inherit', 'inherit'],
dispose: () => {}
}
}
return {
stdio: [opened.stdio[0], json ? process.stderr : opened.stdio[1], opened.stdio[2]],
dispose: opened.dispose
}
}
}))
import { ACCOUNT_HANDLERS } from './account'
import type { HandlerContext } from '../dispatch'
@@ -105,6 +125,7 @@ describe('account CLI handlers', () => {
beforeEach(() => {
Object.defineProperty(process, 'platform', originalPlatform)
spawnMock.mockReset().mockImplementation(() => successfulChild())
openWindowsInteractiveStdioMock.mockReset().mockReturnValue('inherit')
resolveCliCommandMock.mockReset().mockImplementation((command: string) => command)
getVersionManagerBinPathsMock.mockReset().mockReturnValue([])
readKeychainMock.mockReset().mockResolvedValue(null)
@@ -159,6 +180,40 @@ describe('account CLI handlers', () => {
})
})
it('passes Windows console device handles to the login child instead of inheriting Electron stdio', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const dispose = vi.fn()
openWindowsInteractiveStdioMock.mockReturnValue({
stdio: [11, 12, 12],
dispose
})
await ACCOUNT_HANDLERS['account add'](context('codex'))
expect(spawnMock).toHaveBeenCalledWith(
'codex',
['login', '--device-auth'],
expect.objectContaining({ stdio: [11, 12, 12] })
)
expect(dispose).toHaveBeenCalledTimes(1)
})
it('keeps JSON login prompts off the CLI stdout envelope when console fds are attached', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
openWindowsInteractiveStdioMock.mockReturnValue({
stdio: [11, 12, 12],
dispose: vi.fn()
})
await ACCOUNT_HANDLERS['account add'](context('codex', true))
expect(spawnMock).toHaveBeenCalledWith(
'codex',
['login', '--device-auth'],
expect.objectContaining({ stdio: [11, process.stderr, 12] })
)
})
it('routes Windows package-manager shims through the safe cmd launcher', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
resolveCliCommandMock.mockReturnValue('C:\\tools\\codex.cmd')
+4 -1
View File
@@ -21,6 +21,7 @@ import {
UnsafeWindowsBatchArgumentsError,
WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL
} from '../../shared/windows-batch-spawn'
import { stdioForWindowsInteractiveChild } from '../../shared/windows-console-stdio'
import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
import type { RuntimeStatus } from '../../shared/runtime-types'
import type {
@@ -113,12 +114,14 @@ async function runAgentLoginInTerminal(
return
}
const env = addAgentNodePaths({ ...stripElectronRunAsNode(process.env), ...extraEnv })
const consoleStdio = stdioForWindowsInteractiveChild(json)
const child = spawn(spawnCmd, spawnArgs, {
// Why: JSON mode reserves stdout for the response envelope while keeping
// the interactive login attached to the user's terminal via stderr.
stdio: ['inherit', json ? process.stderr : 'inherit', 'inherit'],
stdio: consoleStdio.stdio,
env
})
consoleStdio.dispose()
session.child = child
child.once('error', (error) =>
rejectPromise(
+6 -1
View File
@@ -15,6 +15,7 @@ import {
UnsafeWindowsBatchArgumentsError,
WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL
} from '../../shared/windows-batch-spawn'
import { openWindowsInteractiveStdio } from '../../shared/windows-console-stdio'
import { isSkillsCliAgentKeyShaped, toSkillsCliAgentKeys } from '../../shared/skills-cli-agent-keys'
import {
buildAgentFeatureSkillInstallArgs,
@@ -143,10 +144,14 @@ function runNpxSkills(args: string[]): Promise<number> {
// Why: npx is an `#!/usr/bin/env node` script, so resolving it off PATH is
// not enough — without node alongside it the child exits 127 with no
// 'error' event and the message below never fires.
const consoleStdio = openWindowsInteractiveStdio()
const child = spawn(spawnCmd, spawnArgs, {
stdio: 'inherit',
stdio: consoleStdio === 'inherit' ? 'inherit' : consoleStdio.stdio,
env: { ...process.env, PATH: buildNpxPath(resolved) }
})
if (consoleStdio !== 'inherit') {
consoleStdio.dispose()
}
// Why: a missing npx/Node on a headless host surfaces as a raw spawn ENOENT;
// wrap it so the CLI reports an actionable message like every other failure here.
child.once('error', (error) => {
@@ -6,6 +6,7 @@ import { join } from 'node:path'
import { PassThrough } from 'node:stream'
import type { ClaudeManagedAccount } from '../../shared/managed-account-types'
import { readActiveClaudeKeychainCredentials } from './keychain'
import { getCmdExePath } from '../../shared/windows-batch-spawn'
import {
createService,
resetClaudeKeychainMocks,
@@ -549,9 +550,9 @@ describe('ClaudeAccountService credential capture', () => {
const addPromise = service.addAccount()
await vi.waitFor(() => {
expect(spawnMock).toHaveBeenCalledWith(
process.env.ComSpec ?? 'cmd.exe',
['/d', '/v:off', '/s', '/c', '""claude" "auth" "login" "--claudeai""'],
expect.objectContaining({ shell: false, windowsVerbatimArguments: true })
getCmdExePath(),
['/d', '/c', 'start', '', '/wait', 'claude', 'auth', 'login', '--claudeai'],
expect.objectContaining({ stdio: 'ignore', windowsHide: true })
)
})
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
import { getCmdExePath } from '../../shared/windows-batch-spawn'
import { createService, restorePlatform, setPlatform } from './claude-account-service-test-harness'
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/orca-claude-windows-login-test'
}
}))
vi.mock('../codex-cli/command', () => ({
resolveClaudeCommand: vi.fn(() => 'C:\\Tools\\claude.cmd')
}))
describe('Claude Windows host interactive login', () => {
afterEach(() => {
restorePlatform()
})
it('resolves a Windows auth login whose child has no piped streams', async () => {
setPlatform('win32')
vi.resetModules()
const child = new EventEmitter() as EventEmitter & {
stdout: null
stderr: null
kill: ReturnType<typeof vi.fn>
pid: number
}
child.stdout = null
child.stderr = null
child.kill = vi.fn()
child.pid = 4242
const spawnMock = vi.fn(() => {
queueMicrotask(() => child.emit('exit', 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', 'login', '--claudeai'],
{ windowsPath: 'C:\\tmp\\claude-auth', linuxPath: null, wslDistro: null },
1000
)
expect(spawnMock).toHaveBeenCalledWith(
getCmdExePath(),
expect.arrayContaining(['start', '', '/wait']),
expect.objectContaining({
stdio: 'ignore',
windowsHide: true
})
)
expect(child.kill).not.toHaveBeenCalled()
} finally {
vi.doUnmock('node:child_process')
}
})
})
+34 -10
View File
@@ -35,6 +35,7 @@ import { parseWslUncPath } from '../../shared/wsl-paths'
import { toWindowsWslPath } from '../wsl'
import { runWslProcess } from '../wsl/wsl-runner'
import { buildWindowsCommandInvocation } from './windows-command-invocation'
import { buildWindowsHostInteractiveLoginSpawn } from '../../shared/windows-interactive-login-spawn'
import {
getClaudeSelectionTargetForAccount,
getSelectedClaudeAccountIdForTarget,
@@ -1043,8 +1044,27 @@ export class ClaudeAccountService {
options?: { allowFailure?: boolean; signal?: AbortSignal; keepStdinOpen?: boolean }
): Promise<string> {
return new Promise((resolvePromise, rejectPromise) => {
const spawnConfig =
configDir.linuxPath && configDir.wslDistro
const isWindowsHostInteractiveLogin =
process.platform === 'win32' &&
configDir.linuxPath === null &&
configDir.wslDistro === null &&
args[0] === 'auth' &&
args[1] === 'login'
const interactiveLogin = isWindowsHostInteractiveLogin
? buildWindowsHostInteractiveLoginSpawn(resolveClaudeCommand(), args)
: null
const spawnConfig = interactiveLogin
? {
command: interactiveLogin.command,
args: interactiveLogin.args,
env: {
...process.env,
CLAUDE_CONFIG_DIR: configDir.windowsPath
},
shell: false,
windowsVerbatimArguments: false
}
: configDir.linuxPath && configDir.wslDistro
? {
command: 'wsl.exe',
args: [
@@ -1082,20 +1102,24 @@ export class ClaudeAccountService {
// 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'],
stdio: interactiveLogin
? interactiveLogin.stdio
: [options?.keepStdinOpen ? 'pipe' : 'ignore', 'pipe', 'pipe'],
shell: spawnConfig.shell,
// On Windows this is wsl.exe or a cmd invocation; without the flag it
// opens a console and steals foreground on every managed login (#10488).
windowsHide: true,
windowsVerbatimArguments: spawnConfig.windowsVerbatimArguments,
windowsHide: interactiveLogin?.windowsHide,
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.
detached: process.platform !== 'win32'
})
interactiveLogin?.dispose()
const stdout = child.stdout
const stderr = child.stderr
if (!stdout || !stderr) {
if (!interactiveLogin && (!stdout || !stderr)) {
if (options?.keepStdinOpen) {
child.stdin?.destroy()
}
@@ -1130,8 +1154,8 @@ export class ClaudeAccountService {
clearTimeout(timeout)
timeout = null
}
stdout.off('data', appendOutput)
stderr.off('data', appendOutput)
stdout?.off('data', appendOutput)
stderr?.off('data', appendOutput)
child.off('error', onError)
child.off(completionEvent, onDone)
options?.signal?.removeEventListener('abort', onAbort)
@@ -1139,8 +1163,8 @@ export class ClaudeAccountService {
child.stdin?.destroy()
}
if (completesOnExit) {
stdout.destroy()
stderr.destroy()
stdout?.destroy()
stderr?.destroy()
}
}
const settle = (callback: () => void): void => {
@@ -1229,8 +1253,8 @@ export class ClaudeAccountService {
})
}
stdout.on('data', appendOutput)
stderr.on('data', appendOutput)
stdout?.on('data', appendOutput)
stderr?.on('data', appendOutput)
child.on('error', onError)
// Native Windows browsers can inherit these pipes and indefinitely delay close.
child.on(completionEvent, onDone)
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
import type * as NodeOs from 'node:os'
import { getCmdExePath } from '../../shared/windows-batch-spawn'
import {
createRateLimits,
createRuntimeHome,
createSettings,
createStore,
registerCodexAccountsTestHomes,
testState
} from './service-test-harness'
vi.mock('electron', () => ({
app: {
getPath: () => testState.userDataDir
}
}))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof NodeOs>('node:os')
return {
...actual,
homedir: () => testState.fakeHomeDir
}
})
describe('Codex Windows host interactive login', () => {
registerCodexAccountsTestHomes()
it('resolves a Windows login whose child has no piped streams', async () => {
vi.resetModules()
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })
const child = new EventEmitter() as EventEmitter & {
stdout: null
stderr: null
kill: ReturnType<typeof vi.fn>
pid: number
}
child.stdout = null
child.stderr = null
child.kill = vi.fn()
child.pid = 4242
const spawnMock = vi.fn(() => {
queueMicrotask(() => child.emit('close', 0))
return child
})
vi.doMock('node:child_process', () => ({
execFileSync: vi.fn(),
spawn: spawnMock
}))
vi.doMock('../codex-cli/command', () => ({
resolveCodexCommand: () => 'C:\\Tools\\codex.cmd'
}))
try {
const { CodexAccountService } = await import('./service')
const service = new CodexAccountService(
createStore(createSettings()) as never,
createRateLimits() as never,
createRuntimeHome() as never
)
await (
service as unknown as {
runCodexLogin(managedHomePath: string): Promise<void>
}
).runCodexLogin(testState.fakeHomeDir)
expect(spawnMock).toHaveBeenCalledWith(
getCmdExePath(),
expect.arrayContaining(['start', '', '/wait']),
expect.objectContaining({
stdio: 'ignore',
windowsHide: true
})
)
expect(child.kill).not.toHaveBeenCalled()
} finally {
Object.defineProperty(process, 'platform', originalPlatform)
vi.doUnmock('node:child_process')
vi.doUnmock('../codex-cli/command')
}
})
})
+24 -11
View File
@@ -6,6 +6,7 @@ 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 type {
CodexManagedAccount,
CodexManagedAccountSummary,
@@ -1711,12 +1712,20 @@ export class CodexAccountService {
command: 'wsl.exe',
args: buildWslCodexLoginArgs(wslInfo.distro, wslInfo.linuxPath),
env: process.env,
codexCommand: 'codex'
codexCommand: 'codex',
interactiveLogin: null
}
: (() => {
const codexCommand = resolveCodexCommand()
// Why: Windows codex may be a .cmd/.bat; spawn+shell:true would trigger DEP0190, so invoke cmd.exe /c explicitly.
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(codexCommand, ['login'])
// Why: Windows host login needs a real console; otherwise inherit/hide
// leaves the child unable to read a paste-code / device-auth prompt.
const interactiveLogin =
process.platform === 'win32'
? buildWindowsHostInteractiveLoginSpawn(codexCommand, ['login'])
: null
const { spawnCmd, spawnArgs } = interactiveLogin
? { spawnCmd: interactiveLogin.command, spawnArgs: interactiveLogin.args }
: getSpawnArgsForWindows(codexCommand, ['login'])
return {
command: spawnCmd,
args: spawnArgs,
@@ -1724,15 +1733,19 @@ export class CodexAccountService {
...process.env,
CODEX_HOME: managedHomePath
},
codexCommand
codexCommand,
interactiveLogin
}
})()
const child = spawn(spawnConfig.command, spawnConfig.args, {
stdio: ['ignore', 'pipe', 'pipe'],
// Why: prevents a console window flash for .cmd/.bat entrypoints routed through cmd.exe on Windows.
windowsHide: true,
stdio: spawnConfig.interactiveLogin
? spawnConfig.interactiveLogin.stdio
: ['ignore', 'pipe', 'pipe'],
// Why: hide the outer wrapper only. A dedicated login console stays visible.
windowsHide: spawnConfig.interactiveLogin?.windowsHide ?? true,
env: spawnConfig.env
})
spawnConfig.interactiveLogin?.dispose()
let settled = false
let output = ''
@@ -1761,8 +1774,8 @@ export class CodexAccountService {
clearTimeout(postAuthExitTimeout)
postAuthExitTimeout = null
}
child.stdout.off('data', appendOutput)
child.stderr.off('data', appendOutput)
child.stdout?.off('data', appendOutput)
child.stderr?.off('data', appendOutput)
child.off('error', onError)
child.off('close', onClose)
}
@@ -1845,8 +1858,8 @@ export class CodexAccountService {
})
}
child.stdout.on('data', appendOutput)
child.stderr.on('data', appendOutput)
child.stdout?.on('data', appendOutput)
child.stderr?.on('data', appendOutput)
child.on('error', onError)
child.on('close', onClose)
})
+32
View File
@@ -6,6 +6,7 @@ import {
getCmdExePath,
getRegExePath,
getSpawnArgsForWindows,
wrapWindowsStartWait,
isPermissionError,
isWindowsBatchScript,
resolveWindowsCommand,
@@ -119,6 +120,37 @@ describe('getSpawnArgsForWindows', () => {
})
})
it('wraps an interactive login in start /wait with an empty title argv', () => {
withPlatform('win32', () => {
const { spawnCmd, spawnArgs } = wrapWindowsStartWait(getCmdExePath(), [
'/d',
'/c',
'C:\\Tools\\claude.cmd',
'auth',
'login',
'--claudeai'
])
expect(spawnCmd).toBe(getCmdExePath())
expect(spawnArgs).toEqual([
'/d',
'/c',
'start',
'',
'/wait',
getCmdExePath(),
'/d',
'/c',
'C:\\Tools\\claude.cmd',
'auth',
'login',
'--claudeai'
])
expect(spawnArgs[3]).toBe('')
expect(spawnArgs).not.toContain('/B')
expect(spawnArgs).not.toContain('""')
})
})
it('leaves .exe GUI launches alone even when detachedGui is requested', () => {
withPlatform('win32', () => {
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(
+1
View File
@@ -5,6 +5,7 @@ import { existsSync } from 'node:fs'
export {
getCmdExePath,
getSpawnArgsForWindows,
wrapWindowsStartWait,
isWindowsBatchScript,
WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR,
WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL,
+15
View File
@@ -87,3 +87,18 @@ export function getSpawnArgsForWindows(
}
return { spawnCmd: command, spawnArgs: args }
}
/**
* Inverse of `detachedGui`'s `start "" /B`: a visible console that waits.
* Title is the empty argv entry so libuv emits `""` rather than `"\"\""`.
*/
export function wrapWindowsStartWait(
spawnCmd: string,
spawnArgs: string[]
): { spawnCmd: string; spawnArgs: string[] } {
const cmdExePath = getCmdExePath()
return {
spawnCmd: cmdExePath,
spawnArgs: ['/d', '/c', 'start', '', '/wait', spawnCmd, ...spawnArgs]
}
}
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest'
import {
openWindowsInteractiveStdio,
stdioForWindowsInteractiveChild,
WINDOWS_CONSOLE_INPUT_DEVICE,
WINDOWS_CONSOLE_OUTPUT_DEVICE
} from './windows-console-stdio'
describe('openWindowsInteractiveStdio', () => {
it('returns inherit off Windows without opening devices', () => {
const openSync = vi.fn()
expect(openWindowsInteractiveStdio({ platform: 'darwin', openSync })).toBe('inherit')
expect(openWindowsInteractiveStdio({ platform: 'linux', openSync })).toBe('inherit')
expect(openSync).not.toHaveBeenCalled()
})
it('opens CONIN$ and CONOUT$ on win32 and disposes unique fds once', () => {
const openSync = vi.fn((path: string) => (path === WINDOWS_CONSOLE_INPUT_DEVICE ? 11 : 12))
const closeSync = vi.fn()
const opened = openWindowsInteractiveStdio({ platform: 'win32', openSync, closeSync })
expect(opened).not.toBe('inherit')
if (opened === 'inherit') {
return
}
expect(openSync).toHaveBeenCalledWith(WINDOWS_CONSOLE_INPUT_DEVICE, 'r+')
expect(openSync).toHaveBeenCalledWith(WINDOWS_CONSOLE_OUTPUT_DEVICE, 'w')
expect(opened.stdio).toEqual([11, 12, 12])
opened.dispose()
opened.dispose()
expect(closeSync).toHaveBeenCalledTimes(2)
expect(closeSync).toHaveBeenCalledWith(11)
expect(closeSync).toHaveBeenCalledWith(12)
})
it('returns inherit and closes a partial CONIN$ open when CONOUT$ fails', () => {
const openSync = vi.fn((path: string) => {
if (path === WINDOWS_CONSOLE_INPUT_DEVICE) {
return 11
}
throw new Error('no console')
})
const closeSync = vi.fn()
expect(openWindowsInteractiveStdio({ platform: 'win32', openSync, closeSync })).toBe('inherit')
expect(closeSync).toHaveBeenCalledWith(11)
})
it('returns inherit when neither device can be opened', () => {
const openSync = vi.fn(() => {
throw new Error('no console')
})
const closeSync = vi.fn()
expect(openWindowsInteractiveStdio({ platform: 'win32', openSync, closeSync })).toBe('inherit')
expect(closeSync).not.toHaveBeenCalled()
})
})
describe('stdioForWindowsInteractiveChild', () => {
it('keeps JSON stdout on the CLI envelope stream', () => {
const openSync = vi.fn((path: string) => (path === WINDOWS_CONSOLE_INPUT_DEVICE ? 11 : 12))
const { stdio, dispose } = stdioForWindowsInteractiveChild(true, {
platform: 'win32',
openSync,
closeSync: vi.fn()
})
expect(stdio).toEqual([11, process.stderr, 12])
dispose()
})
})
+78
View File
@@ -0,0 +1,78 @@
import { closeSync as fsCloseSync, openSync as fsOpenSync } from 'node:fs'
import type { StdioOptions } from 'node:child_process'
export const WINDOWS_CONSOLE_INPUT_DEVICE = '\\\\.\\CONIN$'
export const WINDOWS_CONSOLE_OUTPUT_DEVICE = '\\\\.\\CONOUT$'
export type WindowsInteractiveStdio = {
stdio: [number, number, number]
dispose: () => void
}
type OpenWindowsInteractiveStdioDeps = {
platform?: NodeJS.Platform
openSync?: (path: string, flags: string) => number
closeSync?: (fd: number) => void
}
function closeUniqueFds(fds: number[], close: typeof fsCloseSync): void {
for (const fd of new Set(fds)) {
try {
close(fd)
} catch {
// Already closed by a previous dispose or a failed partial open.
}
}
}
/**
* Opens the Windows console devices so an interactive child of a GUI-subsystem
* Electron CLI can actually read typed input. Do not trust `stdin.isTTY`.
*/
export function openWindowsInteractiveStdio(
deps: OpenWindowsInteractiveStdioDeps = {}
): WindowsInteractiveStdio | 'inherit' {
const platform = deps.platform ?? process.platform
if (platform !== 'win32') {
return 'inherit'
}
const openSync = deps.openSync ?? ((path: string, flags: string) => fsOpenSync(path, flags))
const closeSync = deps.closeSync ?? fsCloseSync
const opened: number[] = []
try {
opened.push(openSync(WINDOWS_CONSOLE_INPUT_DEVICE, 'r+'))
opened.push(openSync(WINDOWS_CONSOLE_OUTPUT_DEVICE, 'w'))
const [stdin, stdout] = opened
let disposed = false
return {
stdio: [stdin, stdout, stdout],
dispose: () => {
if (disposed) {
return
}
disposed = true
closeUniqueFds(opened, closeSync)
}
}
} catch {
closeUniqueFds(opened, closeSync)
return 'inherit'
}
}
export function stdioForWindowsInteractiveChild(
json: boolean,
deps: OpenWindowsInteractiveStdioDeps = {}
): { stdio: StdioOptions; dispose: () => void } {
const opened = openWindowsInteractiveStdio(deps)
if (opened === 'inherit') {
return {
stdio: ['inherit', json ? process.stderr : 'inherit', 'inherit'],
dispose: () => {}
}
}
return {
stdio: [opened.stdio[0], json ? process.stderr : opened.stdio[1], opened.stdio[2]],
dispose: opened.dispose
}
}
@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from 'vitest'
import { getCmdExePath } from './windows-batch-spawn'
import { buildWindowsHostInteractiveLoginSpawn } from './windows-interactive-login-spawn'
describe('buildWindowsHostInteractiveLoginSpawn', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!
it('passes console device fds when CONIN$ opens', () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
const dispose = vi.fn()
const spawn = buildWindowsHostInteractiveLoginSpawn('claude.exe', ['auth', 'login'], () => ({
stdio: [11, 12, 12],
dispose
}))
expect(spawn).toEqual({
command: 'claude.exe',
args: ['auth', 'login'],
stdio: [11, 12, 12],
windowsHide: false,
dispose
})
} finally {
Object.defineProperty(process, 'platform', originalPlatform)
}
})
it('falls back to a hidden start /wait wrapper when no console exists', () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
const spawn = buildWindowsHostInteractiveLoginSpawn(
'C:\\Tools\\claude.cmd',
['auth', 'login', '--claudeai'],
() => 'inherit'
)
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.stdio).toBe('ignore')
expect(spawn.windowsHide).toBe(true)
} finally {
Object.defineProperty(process, 'platform', originalPlatform)
}
})
})
@@ -0,0 +1,36 @@
import { getSpawnArgsForWindows, wrapWindowsStartWait } from './windows-batch-spawn'
import { openWindowsInteractiveStdio, type WindowsInteractiveStdio } from './windows-console-stdio'
export type WindowsHostInteractiveLoginSpawn = {
command: string
args: string[]
stdio: WindowsInteractiveStdio['stdio'] | 'ignore'
windowsHide: boolean
dispose: () => void
}
export function buildWindowsHostInteractiveLoginSpawn(
command: string,
args: string[],
openStdio: typeof openWindowsInteractiveStdio = openWindowsInteractiveStdio
): WindowsHostInteractiveLoginSpawn {
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, args)
const opened = openStdio()
if (opened !== 'inherit') {
return {
command: spawnCmd,
args: spawnArgs,
stdio: opened.stdio,
windowsHide: false,
dispose: opened.dispose
}
}
const wrapped = wrapWindowsStartWait(spawnCmd, spawnArgs)
return {
command: wrapped.spawnCmd,
args: wrapped.spawnArgs,
stdio: 'ignore',
windowsHide: true,
dispose: () => {}
}
}