From 04fae9c8844af2ddf85776ada45799727c08ec8a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:55:43 -0700 Subject: [PATCH] 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 --- src/cli/handlers/account.test.ts | 55 ++++++++++++ src/cli/handlers/account.ts | 5 +- src/cli/handlers/skills.ts | 7 +- ...aude-account-service-login-process.test.ts | 7 +- .../claude-windows-interactive-login.test.ts | 74 ++++++++++++++++ src/main/claude-accounts/service.ts | 44 +++++++--- .../codex-windows-interactive-login.test.ts | 85 +++++++++++++++++++ src/main/codex-accounts/service.ts | 35 +++++--- src/main/win32-utils.test.ts | 32 +++++++ src/main/win32-utils.ts | 1 + src/shared/windows-batch-spawn.ts | 15 ++++ src/shared/windows-console-stdio.test.ts | 68 +++++++++++++++ src/shared/windows-console-stdio.ts | 78 +++++++++++++++++ .../windows-interactive-login-spawn.test.ts | 57 +++++++++++++ src/shared/windows-interactive-login-spawn.ts | 36 ++++++++ 15 files changed, 573 insertions(+), 26 deletions(-) create mode 100644 src/main/claude-accounts/claude-windows-interactive-login.test.ts create mode 100644 src/main/codex-accounts/codex-windows-interactive-login.test.ts create mode 100644 src/shared/windows-console-stdio.test.ts create mode 100644 src/shared/windows-console-stdio.ts create mode 100644 src/shared/windows-interactive-login-spawn.test.ts create mode 100644 src/shared/windows-interactive-login-spawn.ts diff --git a/src/cli/handlers/account.test.ts b/src/cli/handlers/account.test.ts index 8642df27ea4..5750b663ec6 100644 --- a/src/cli/handlers/account.test.ts +++ b/src/cli/handlers/account.test.ts @@ -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') diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index 0f7f95bc17e..8c7eab5afef 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -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( diff --git a/src/cli/handlers/skills.ts b/src/cli/handlers/skills.ts index 4e8db5dbdae..cbd3c6d4cfa 100644 --- a/src/cli/handlers/skills.ts +++ b/src/cli/handlers/skills.ts @@ -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 { // 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) => { 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 cb387a659d5..1549acd494d 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 @@ -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 }) ) }) diff --git a/src/main/claude-accounts/claude-windows-interactive-login.test.ts b/src/main/claude-accounts/claude-windows-interactive-login.test.ts new file mode 100644 index 00000000000..2076658d3d9 --- /dev/null +++ b/src/main/claude-accounts/claude-windows-interactive-login.test.ts @@ -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 + 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 + } + ).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') + } + }) +}) diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 2e3206eab69..113707181d1 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -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 { 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) diff --git a/src/main/codex-accounts/codex-windows-interactive-login.test.ts b/src/main/codex-accounts/codex-windows-interactive-login.test.ts new file mode 100644 index 00000000000..57a1d9a3163 --- /dev/null +++ b/src/main/codex-accounts/codex-windows-interactive-login.test.ts @@ -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('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 + 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 + } + ).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') + } + }) +}) diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index cd790207823..9dbd9f5d20b 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -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) }) diff --git a/src/main/win32-utils.test.ts b/src/main/win32-utils.test.ts index 381fef6643e..72f93b765ab 100644 --- a/src/main/win32-utils.test.ts +++ b/src/main/win32-utils.test.ts @@ -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( diff --git a/src/main/win32-utils.ts b/src/main/win32-utils.ts index 151dc9306f0..5798f22be5a 100644 --- a/src/main/win32-utils.ts +++ b/src/main/win32-utils.ts @@ -5,6 +5,7 @@ import { existsSync } from 'node:fs' export { getCmdExePath, getSpawnArgsForWindows, + wrapWindowsStartWait, isWindowsBatchScript, WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL, diff --git a/src/shared/windows-batch-spawn.ts b/src/shared/windows-batch-spawn.ts index d8312d2ec27..6c5c7e7d496 100644 --- a/src/shared/windows-batch-spawn.ts +++ b/src/shared/windows-batch-spawn.ts @@ -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] + } +} diff --git a/src/shared/windows-console-stdio.test.ts b/src/shared/windows-console-stdio.test.ts new file mode 100644 index 00000000000..bcd68469c9c --- /dev/null +++ b/src/shared/windows-console-stdio.test.ts @@ -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() + }) +}) diff --git a/src/shared/windows-console-stdio.ts b/src/shared/windows-console-stdio.ts new file mode 100644 index 00000000000..7ca5c3ca1e4 --- /dev/null +++ b/src/shared/windows-console-stdio.ts @@ -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 + } +} diff --git a/src/shared/windows-interactive-login-spawn.test.ts b/src/shared/windows-interactive-login-spawn.test.ts new file mode 100644 index 00000000000..91a8f196e1f --- /dev/null +++ b/src/shared/windows-interactive-login-spawn.test.ts @@ -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) + } + }) +}) diff --git a/src/shared/windows-interactive-login-spawn.ts b/src/shared/windows-interactive-login-spawn.ts new file mode 100644 index 00000000000..39615e7bc54 --- /dev/null +++ b/src/shared/windows-interactive-login-spawn.ts @@ -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: () => {} + } +}