mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
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:
@@ -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')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { existsSync } from 'node:fs'
|
||||
export {
|
||||
getCmdExePath,
|
||||
getSpawnArgsForWindows,
|
||||
wrapWindowsStartWait,
|
||||
isWindowsBatchScript,
|
||||
WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR,
|
||||
WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL,
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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: () => {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user