diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 91cb45da285..a2d385bd698 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -141,13 +141,17 @@ module.exports = { 'out/main/agent-hooks/**', 'out/main/antigravity/**', 'out/main/claude/**', + 'out/main/claude-accounts/keychain.js', 'out/main/codex/**', + 'out/main/codex-cli/command.js', 'out/main/copilot/**', 'out/main/cursor/**', 'out/main/droid/**', 'out/main/gemini/**', 'out/main/grok/**', 'out/main/hermes/**', + 'out/main/ipc/local-agent-install-dir-detection.js', + 'out/main/ipc/tui-agent-detection-commands.js', 'out/main/win32-utils.js', 'out/main/daemon-entry.js', 'out/main/plugin-host-entry.js', diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 9712e5edace..f90d6e49b5a 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -154,7 +154,16 @@ describe('electron-builder config', () => { it('unpacks the compiled CommonJS boundary with CLI runtime files', () => { expect(electronBuilderConfig.asarUnpack).toEqual( - expect.arrayContaining(['out/package.json', 'out/cli/**', 'out/shared/**']) + expect.arrayContaining([ + 'out/package.json', + 'out/cli/**', + 'out/shared/**', + 'out/main/claude-accounts/keychain.js', + 'out/main/codex-cli/command.js', + 'out/main/ipc/local-agent-install-dir-detection.js', + 'out/main/ipc/tui-agent-detection-commands.js', + 'out/main/win32-utils.js' + ]) ) }) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 646d612e215..6bbfa8814f3 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -19,6 +19,7 @@ "../src/main/claude/hook-settings.ts", "../src/main/claude/hook-service.ts", "../src/main/claude/statusline-script.ts", + "../src/main/claude-accounts/keychain.ts", "../src/main/codex/codex-app-server-capability-cache.ts", "../src/main/codex/codex-app-server-capability-signal.ts", "../src/main/codex/codex-app-server-client.ts", diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 7388211487a..d2acd8a6c0b 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -224,7 +224,17 @@ export const electronViteConfig: UserConfig = { // this path for `orca agent hooks ...`, so it must survive rebuilds. 'agent-hooks/managed-agent-hook-controls': resolve( 'src/main/agent-hooks/managed-agent-hook-controls.ts' - ) + ), + 'ipc/local-agent-install-dir-detection': resolve( + 'src/main/ipc/local-agent-install-dir-detection.ts' + ), + 'ipc/tui-agent-detection-commands': resolve( + 'src/main/ipc/tui-agent-detection-commands.ts' + ), + // Why: same rule — `orca account add` / `account list` import these. + 'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts'), + 'codex-cli/command': resolve('src/main/codex-cli/command.ts'), + 'win32-utils': resolve('src/main/win32-utils.ts') }, // Why: Rolldown's SSR default is ESM, but Electron and sidecar launchers // consume these stable CommonJS paths. diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index f6f72111565..eeedfbe0428 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -232,6 +232,11 @@ describe('supportsBrowserPageFlag', () => { it('does not expose browser page targeting on local agent discovery', () => { expect(supportsBrowserPageFlag(['agent-context'])).toBe(false) }) + + it('does not expose browser page targeting on host-local account commands', () => { + expect(supportsBrowserPageFlag(['account', 'add'])).toBe(false) + expect(supportsBrowserPageFlag(['account', 'list'])).toBe(false) + }) }) describe('validateCommandAndFlags', () => { diff --git a/src/cli/args.ts b/src/cli/args.ts index a5150c980e5..b7a3be791e3 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -152,6 +152,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean { } if ( [ + 'account', 'automations', 'project', 'repo', @@ -198,6 +199,7 @@ export function isCommandGroup(commandPath: string[]): boolean { return ( (commandPath.length === 1 && [ + 'account', 'automations', 'project', 'repo', diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts index 6b2c7410c49..1cafa87cb95 100644 --- a/src/cli/handler-group-manifest.ts +++ b/src/cli/handler-group-manifest.ts @@ -17,6 +17,11 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [ keys: ['claude-teams', 'open', 'serve', 'status'], load: async () => (await import('./handlers/core.js')).CORE_HANDLERS }, + { + name: 'account', + keys: ['account add', 'account list'], + load: async () => (await import('./handlers/account.js')).ACCOUNT_HANDLERS + }, { name: 'automations', keys: [ diff --git a/src/cli/handlers/account.test.ts b/src/cli/handlers/account.test.ts new file mode 100644 index 00000000000..97f7bee9c92 --- /dev/null +++ b/src/cli/handlers/account.test.ts @@ -0,0 +1,565 @@ +import { EventEmitter } from 'node:events' +import { createHash } from 'node:crypto' +import { existsSync } from 'node:fs' +import { delimiter } from 'node:path' +import type * as NodeFs from 'node:fs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + deleteKeychainMock, + getVersionManagerBinPathsMock, + readKeychainMock, + resolveCliCommandMock, + rmSyncMock, + spawnMock, + writeKeychainMock +} = vi.hoisted(() => ({ + deleteKeychainMock: vi.fn(), + getVersionManagerBinPathsMock: vi.fn(), + readKeychainMock: vi.fn(), + resolveCliCommandMock: vi.fn(), + rmSyncMock: vi.fn(), + spawnMock: vi.fn(), + writeKeychainMock: vi.fn() +})) + +// Why: keep real temp-dir cleanup by default so leak assertions stay honest, +// while allowing deterministic Windows EBUSY coverage. +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + rmSyncMock.mockImplementation(actual.rmSync) + return { ...actual, rmSync: rmSyncMock } +}) + +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), + execFileSync: vi.fn(), + spawn: spawnMock +})) +vi.mock('../../main/claude-accounts/keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: deleteKeychainMock, + readActiveClaudeKeychainCredentialsStrict: readKeychainMock, + writeActiveClaudeKeychainCredentials: writeKeychainMock +})) +vi.mock('../../main/codex-cli/command', () => ({ + getVersionManagerBinPaths: getVersionManagerBinPathsMock, + resolveCliCommand: resolveCliCommandMock +})) + +import { ACCOUNT_HANDLERS } from './account' +import type { HandlerContext } from '../dispatch' +import type { RuntimeClient } from '../runtime-client' +import { getCmdExePath } from '../../main/win32-utils' +import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' + +function successfulChild(): EventEmitter { + const child = new EventEmitter() + queueMicrotask(() => child.emit('exit', 0)) + return child +} + +// Why: identify the handler under test by set difference, not by position — +// `.at(-1)` picks up any listener a later registration appends (vitest installs +// its own once-wrapped SIGINT teardown), which made assertions flake. +function newSignalListener( + signal: NodeJS.Signals, + before: readonly unknown[] +): (signal: NodeJS.Signals) => void { + const added = process.listeners(signal).filter((listener) => !before.includes(listener)) + if (added.length !== 1) { + throw new Error(`Expected 1 new ${signal} listener, found ${added.length}`) + } + return added[0] as (signal: NodeJS.Signals) => void +} + +function accountState(email: string) { + return { + accounts: [{ id: 'account-1', email }], + activeAccountId: 'account-1', + activeAccountIdsByRuntime: { host: 'account-1', wsl: {} } + } +} + +describe('account CLI handlers', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + const originalElectronRunAsNode = process.env.ELECTRON_RUN_AS_NODE + const originalPathAlias = process.env.Path + const callMock = vi.fn() + const client = { call: callMock } as unknown as RuntimeClient + let logSpy: ReturnType + + function context(agent: string, json = false): HandlerContext { + return { + client, + cwd: process.cwd(), + flags: new Map([['agent', agent]]), + json, + rawArgs: [] + } + } + + beforeEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) + spawnMock.mockReset().mockImplementation(() => successfulChild()) + resolveCliCommandMock.mockReset().mockImplementation((command: string) => command) + getVersionManagerBinPathsMock.mockReset().mockReturnValue([]) + readKeychainMock.mockReset().mockResolvedValue(null) + deleteKeychainMock.mockReset().mockResolvedValue(undefined) + writeKeychainMock.mockReset().mockResolvedValue(undefined) + callMock.mockReset().mockImplementation((method: string) => + Promise.resolve({ + id: 'test', + ok: true, + result: + method === 'status.get' + ? { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] } + : accountState(method.includes('Claude') ? 'claude@example.com' : 'codex@example.com'), + _meta: { runtimeId: 'test-runtime' } + }) + ) + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + process.env.ELECTRON_RUN_AS_NODE = '1' + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) + logSpy.mockRestore() + if (originalElectronRunAsNode === undefined) { + delete process.env.ELECTRON_RUN_AS_NODE + } else { + process.env.ELECTRON_RUN_AS_NODE = originalElectronRunAsNode + } + if (originalPathAlias === undefined) { + delete process.env.Path + } else { + process.env.Path = originalPathAlias + } + }) + + it('uses Codex device auth and keeps JSON stdout clean', async () => { + await ACCOUNT_HANDLERS['account add'](context('codex', true)) + + expect(spawnMock).toHaveBeenCalledWith( + 'codex', + ['login', '--device-auth'], + expect.objectContaining({ + stdio: ['inherit', process.stderr, 'inherit'], + env: expect.objectContaining({ CODEX_HOME: expect.any(String) }) + }) + ) + const spawnOptions = spawnMock.mock.calls[0]?.[2] + expect(spawnOptions.env.ELECTRON_RUN_AS_NODE).toBeUndefined() + expect(existsSync(spawnOptions.env.CODEX_HOME)).toBe(false) + expect(callMock).toHaveBeenCalledWith('accounts.addCodexFromHome', { + sourceHome: spawnOptions.env.CODEX_HOME + }) + }) + + 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') + + await ACCOUNT_HANDLERS['account add'](context('codex')) + + expect(spawnMock).toHaveBeenCalledWith( + getCmdExePath(), + ['/d', '/c', 'C:\\tools\\codex.cmd', 'login', '--device-auth'], + expect.objectContaining({ stdio: ['inherit', 'inherit', 'inherit'] }) + ) + }) + + it('adds version-manager Node paths to the login child environment', async () => { + const nodeBin = '/home/test/.nvm/versions/node/v22.0.0/bin' + getVersionManagerBinPathsMock.mockReturnValue([nodeBin]) + + await ACCOUNT_HANDLERS['account add'](context('codex')) + + const path = spawnMock.mock.calls[0]?.[2].env.PATH as string + expect(path.split(delimiter)[0]).toBe(nodeBin) + }) + + it('updates the effective Windows PATH regardless of native environment casing', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + process.env.Path = 'C:\\stale' + const nodeBin = 'C:\\Users\\test\\.volta\\bin' + const effectivePathBefore = process.env.PATH ?? process.env.Path ?? '' + getVersionManagerBinPathsMock.mockReturnValue([nodeBin]) + + await ACCOUNT_HANDLERS['account add'](context('codex')) + + const env = spawnMock.mock.calls[0]?.[2].env as NodeJS.ProcessEnv + const pathValues = Object.entries(env) + .filter(([key]) => key.toLowerCase() === 'path') + .map(([, value]) => value) + expect(pathValues).toContain(`${nodeBin}${delimiter}${effectivePathBefore}`) + }) + + it('removes scoped Claude credentials and restores the legacy Keychain item', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readKeychainMock.mockResolvedValue('legacy-credentials') + + await ACCOUNT_HANDLERS['account add'](context('claude')) + + const configDir = spawnMock.mock.calls[0]?.[2].env.CLAUDE_CONFIG_DIR + expect(deleteKeychainMock).toHaveBeenCalledWith(configDir) + expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials') + expect(callMock).toHaveBeenCalledWith('accounts.addClaudeFromConfigDir', { + configDir, + previousLegacyCredentialsSha256: createHash('sha256') + .update('legacy-credentials') + .digest('hex') + }) + expect(existsSync(configDir)).toBe(false) + }) + + it('waits for physical child close before removing interrupted login credentials', async () => { + // Why: deleting first lets the still-live login recreate credentials afterward. + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const kill = vi.fn() + const child = Object.assign(new EventEmitter(), { kill }) + let codexHome = '' + spawnMock.mockImplementation((_command, _args, options: { env: Record }) => { + codexHome = options.env.CODEX_HOME + return child + }) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + const listenersBefore = process.listeners('SIGINT') + + const pending = ACCOUNT_HANDLERS['account add'](context('codex')).catch(() => {}) + await vi.waitFor(() => expect(codexHome).not.toBe('')) + expect(existsSync(codexHome)).toBe(true) + + newSignalListener('SIGINT', listenersBefore)('SIGINT') + + await vi.waitFor(() => expect(kill).toHaveBeenCalledWith('SIGINT')) + expect(exitSpy).not.toHaveBeenCalled() + expect(existsSync(codexHome)).toBe(true) + + child.emit('exit', 1) + child.emit('close', 1) + await vi.waitFor(() => expect(exitSpy).toHaveBeenCalledWith(130)) + expect(existsSync(codexHome)).toBe(false) + expect(callMock).not.toHaveBeenCalledWith('accounts.addCodexFromHome', expect.anything()) + + await pending + exitSpy.mockRestore() + }) + + it('cleans up when an SSH hangup ends the login', async () => { + // Why: this flow targets headless/SSH hosts, where a dropped connection + // delivers SIGHUP — Node's default terminates without running cleanup. + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const child = Object.assign(new EventEmitter(), { kill: vi.fn() }) + let codexHome = '' + spawnMock.mockImplementation((_command, _args, options: { env: Record }) => { + codexHome = options.env.CODEX_HOME + return child + }) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + const listenersBefore = process.listeners('SIGHUP') + + const pending = ACCOUNT_HANDLERS['account add'](context('codex')).catch(() => {}) + await vi.waitFor(() => expect(codexHome).not.toBe('')) + + newSignalListener('SIGHUP', listenersBefore)('SIGHUP') + + await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGHUP')) + expect(exitSpy).not.toHaveBeenCalled() + expect(existsSync(codexHome)).toBe(true) + + child.emit('exit', 1) + child.emit('close', 1) + await vi.waitFor(() => expect(exitSpy).toHaveBeenCalledWith(129)) + expect(existsSync(codexHome)).toBe(false) + + await pending + exitSpy.mockRestore() + }) + + it('waits for in-flight cleanup when a second signal arrives', async () => { + // Why: a boolean latch lets the second signal's process.exit fire while the + // first cleanup is still inside a Keychain call, stranding the credentials. + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readKeychainMock.mockResolvedValue('legacy-credentials') + let releaseKeychainDelete: (() => void) | undefined + deleteKeychainMock.mockImplementationOnce( + () => + new Promise((resolvePromise) => { + releaseKeychainDelete = () => resolvePromise() + }) + ) + const child = Object.assign(new EventEmitter(), { kill: vi.fn() }) + let configDir = '' + spawnMock.mockImplementation((_command, _args, options: { env: Record }) => { + configDir = options.env.CLAUDE_CONFIG_DIR + return child + }) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + const sigintBefore = process.listeners('SIGINT') + const sigtermBefore = process.listeners('SIGTERM') + + const pending = ACCOUNT_HANDLERS['account add'](context('claude')).catch(() => {}) + await vi.waitFor(() => expect(configDir).not.toBe('')) + + const onSigint = newSignalListener('SIGINT', sigintBefore) + const onSigterm = newSignalListener('SIGTERM', sigtermBefore) + // Why: `rawListeners` exposes the `once` wrapper, so this fails if the handler + // is registered with `once` — where a second Ctrl-C falls through to Node's + // default and kills the process mid-cleanup. + expect(process.rawListeners('SIGINT')).toContain(onSigint) + + onSigint('SIGINT') + await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGINT')) + child.emit('exit', 1) + child.emit('close', 1) + await vi.waitFor(() => expect(deleteKeychainMock).toHaveBeenCalledWith(configDir)) + onSigterm('SIGTERM') + await new Promise((resolvePromise) => { + setImmediate(resolvePromise) + }) + + expect(exitSpy).not.toHaveBeenCalled() + expect(writeKeychainMock).not.toHaveBeenCalled() + expect(existsSync(configDir)).toBe(true) + + releaseKeychainDelete?.() + await vi.waitFor(() => expect(exitSpy).toHaveBeenCalled()) + expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials') + expect(existsSync(configDir)).toBe(false) + expect(child.kill).toHaveBeenCalledOnce() + + await pending + exitSpy.mockRestore() + }) + + it('warns that the account may already be registered when interrupted mid-RPC', async () => { + // Why: the runtime finishes the add independently of this process, so an + // interrupt after sign-in cannot honestly be reported as "not added". + const child = Object.assign(new EventEmitter(), { kill: vi.fn() }) + spawnMock.mockImplementation(() => { + queueMicrotask(() => child.emit('exit', 0)) + return child + }) + // Why: only the registration RPC hangs — the preflight must still resolve. + callMock.mockImplementation((method: string) => + method === 'status.get' + ? Promise.resolve({ + id: 'test', + ok: true, + result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] }, + _meta: { runtimeId: 'test-runtime' } + }) + : method === 'accounts.list' + ? Promise.resolve({ + id: 'test', + ok: true, + result: { claude: accountState('c@e.com'), codex: accountState('x@e.com') }, + _meta: { runtimeId: 'test-runtime' } + }) + : new Promise(() => {}) + ) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + const listenersBefore = process.listeners('SIGINT') + + void ACCOUNT_HANDLERS['account add'](context('codex')).catch(() => {}) + await vi.waitFor(() => + expect(callMock).toHaveBeenCalledWith('accounts.addCodexFromHome', expect.anything()) + ) + + newSignalListener('SIGINT', listenersBefore)('SIGINT') + + await vi.waitFor(() => expect(exitSpy).toHaveBeenCalledWith(130)) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('may still have been registered')) + warnSpy.mockRestore() + exitSpy.mockRestore() + }) + + it('stays armed for signals until post-success cleanup finishes', async () => { + // Why: detaching the handlers before cleanup leaves the multi-second Keychain + // calls covered only by Node's default handling, which kills mid-cleanup. + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readKeychainMock.mockResolvedValue('legacy-credentials') + let releaseKeychainDelete: (() => void) | undefined + deleteKeychainMock.mockImplementationOnce( + () => + new Promise((resolvePromise) => { + releaseKeychainDelete = () => resolvePromise() + }) + ) + + const listenersBefore = process.listeners('SIGINT') + + const pending = ACCOUNT_HANDLERS['account add'](context('claude')) + await vi.waitFor(() => expect(deleteKeychainMock).toHaveBeenCalled()) + + // Why: cleanup is still in flight here, so this add's guard must still be installed. + const handler = newSignalListener('SIGINT', listenersBefore) + + releaseKeychainDelete?.() + await pending + expect(process.listeners('SIGINT')).not.toContain(handler) + }) + + it('fails before the login when the runtime is unreachable', async () => { + // Why: discovering a dead runtime after sign-in wastes a full OAuth round trip. + callMock.mockRejectedValue(new Error('runtime not running')) + + await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow( + 'runtime not running' + ) + expect(callMock).toHaveBeenCalledWith('status.get') + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('fails before login when the running runtime predates account imports', async () => { + callMock.mockResolvedValue({ + id: 'test', + ok: true, + result: { capabilities: [] }, + _meta: { runtimeId: 'test-runtime' } + }) + + await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow( + 'runtime is too old' + ) + expect(callMock).toHaveBeenCalledOnce() + expect(callMock).toHaveBeenCalledWith('status.get') + expect(spawnMock).not.toHaveBeenCalled() + }) + + it.each(['environment', 'pairing-code'])( + 'rejects --%s instead of silently ignoring it', + async (flag) => { + // Why: account commands are pinned to the local runtime, so honoring these + // silently would register the account on the wrong host. + await expect( + ACCOUNT_HANDLERS['account add']({ + ...context('codex'), + flags: new Map([ + ['agent', 'codex'], + [flag, 'homelab'] + ]) + }) + ).rejects.toThrow(`\`--${flag}\` does not retarget`) + expect(spawnMock).not.toHaveBeenCalled() + } + ) + + it.each(['environment', 'pairing-code'])( + 'rejects --%s on `account list` instead of listing the local host', + async (flag) => { + // Why: listing is read-only, but answering with the LOCAL machine's accounts + // when the user named a remote host is the specific wrong answer they'd act on. + await expect( + ACCOUNT_HANDLERS['account list']({ + ...context('claude'), + flags: new Map([[flag, 'homelab']]) + }) + ).rejects.toThrow(`\`--${flag}\` does not retarget`) + expect(callMock).not.toHaveBeenCalled() + } + ) + + it('keeps the original add error when cleanup also fails', async () => { + // Why: cleanup runs in a `finally`, so an unguarded rejection there replaces + // the error that actually explains why the add failed. + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + rmSyncMock.mockImplementationOnce(() => { + throw new Error('EBUSY: resource busy or locked') + }) + callMock.mockImplementation((method: string) => + method === 'status.get' + ? Promise.resolve({ + id: 'test', + ok: true, + result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] }, + _meta: { runtimeId: 'test-runtime' } + }) + : method === 'accounts.list' + ? Promise.resolve({ + id: 'test', + ok: true, + result: { claude: accountState('c@e.com'), codex: accountState('x@e.com') }, + _meta: { runtimeId: 'test-runtime' } + }) + : Promise.reject(new Error('registration rejected by runtime')) + ) + + await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow( + 'registration rejected by runtime' + ) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to clean up the temporary login directory'), + expect.any(Error) + ) + warnSpy.mockRestore() + }) + + it('fails a successful add when the temporary credentials cannot be removed', async () => { + rmSyncMock.mockImplementationOnce(() => { + throw new Error('EBUSY: resource busy or locked') + }) + + await expect(ACCOUNT_HANDLERS['account add'](context('codex', true))).rejects.toThrow('EBUSY') + expect(logSpy).not.toHaveBeenCalled() + }) + + it('fails a successful Claude add when Keychain cleanup fails', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readKeychainMock.mockResolvedValue('legacy-credentials') + deleteKeychainMock.mockRejectedValueOnce(new Error('Keychain denied cleanup')) + + await expect(ACCOUNT_HANDLERS['account add'](context('claude'))).rejects.toThrow( + 'Failed to clean up Claude login artifacts' + ) + expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials') + }) + + it('rejects `--agent` with no value instead of defaulting to Claude', async () => { + // Why: the parser turns a valueless flag into boolean true, so a silent + // default would run a full OAuth login for the wrong provider. + await expect( + ACCOUNT_HANDLERS['account add']({ ...context('claude'), flags: new Map([['agent', true]]) }) + ).rejects.toThrow('Missing a value for --agent') + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('marks an account selected for WSL as active in human output', async () => { + callMock.mockResolvedValue({ + id: 'test', + ok: true, + result: { + claude: { + accounts: [{ id: 'claude-wsl', email: 'claude@example.com' }], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'claude-wsl' } } + }, + codex: { accounts: [], activeAccountId: null } + }, + _meta: { runtimeId: 'test-runtime' } + }) + + await ACCOUNT_HANDLERS['account list']({ ...context('claude'), flags: new Map() }) + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('claude@example.com (active)')) + }) + + it('lists accounts without forcing a provider usage refresh', async () => { + // Why: the forced lane bypasses the poll throttle and costs one serial + // round-trip per managed account, and this output shows no usage numbers. + callMock.mockResolvedValue({ + id: 'test', + ok: true, + result: { + claude: { accounts: [], activeAccountId: null }, + codex: { accounts: [], activeAccountId: null } + }, + _meta: { runtimeId: 'test-runtime' } + }) + + await ACCOUNT_HANDLERS['account list']({ ...context('claude'), flags: new Map() }) + + expect(callMock).toHaveBeenCalledWith('accounts.list', { refreshUsage: false }) + }) +}) diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts new file mode 100644 index 00000000000..0a7a3212d66 --- /dev/null +++ b/src/cli/handlers/account.ts @@ -0,0 +1,298 @@ +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import type { CommandHandler, HandlerContext } from '../dispatch' +import { printResult } from '../format' +import { RuntimeClientError } from '../runtime-client' +import { stripElectronRunAsNode } from '../runtime/launch' +import { + deleteActiveClaudeKeychainCredentialsStrict, + readActiveClaudeKeychainCredentialsStrict, + writeActiveClaudeKeychainCredentials +} from '../../main/claude-accounts/keychain' +import { getVersionManagerBinPaths, resolveCliCommand } from '../../main/codex-cli/command' +import { getSpawnArgsForWindows } from '../../main/win32-utils' +import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import type { RuntimeStatus } from '../../shared/runtime-types' +import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types' +import { + type InteractiveLoginSession, + withInteractiveLoginCleanup +} from './interactive-login-interruption' + +// Why: add returns just that provider's state; list returns the full snapshot. +type AccountsListSnapshot = { + claude: ClaudeRateLimitAccountsState + codex: CodexRateLimitAccountsState +} + +// Why: Claude and Codex managed-account summaries both carry id+email+active id, +// so one formatter renders either provider's block. +type AccountsBlock = { + accounts: readonly { id: string; email: string }[] + activeAccountId: string | null + activeAccountIdsByRuntime?: { + host: string | null + wsl: Record + } +} + +/** Renders a provider's managed-account list as a human-readable block, marking the active account. */ +function formatAccountsBlock(label: string, block: AccountsBlock): string { + if (block.accounts.length === 0) { + return `No managed ${label} accounts.` + } + const activeAccountIds = new Set([ + block.activeAccountId, + block.activeAccountIdsByRuntime?.host, + ...Object.values(block.activeAccountIdsByRuntime?.wsl ?? {}) + ]) + const lines = block.accounts.map( + (account) => ` ${account.email}${activeAccountIds.has(account.id) ? ' (active)' : ''}` + ) + return `Managed ${label} accounts (${block.accounts.length}):\n${lines.join('\n')}` +} + +function addAgentNodePaths(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const pathKey = + process.platform === 'win32' && env.Path !== undefined && env.PATH === undefined + ? 'Path' + : 'PATH' + const currentEntries = (env[pathKey] ?? '').split(delimiter).filter(Boolean) + const existing = new Set(currentEntries) + const missing = getVersionManagerBinPaths().filter((entry) => !existing.has(entry)) + if (missing.length > 0) { + env[pathKey] = [...missing, ...currentEntries].join(delimiter) + } + return env +} + +/** + * Runs the real agent login attached to the user's terminal so the OAuth + * URL/device-code prompt is visible and the code can be pasted back — the desktop + * GUI flow drives this via a browser Orca can't reach on a headless host. + */ +async function runAgentLoginInTerminal( + command: string, + args: string[], + extraEnv: Record, + json: boolean, + session: InteractiveLoginSession +): Promise { + await new Promise((resolvePromise, rejectPromise) => { + const resolvedCommand = resolveCliCommand(command) + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedCommand, args) + const env = addAgentNodePaths({ ...stripElectronRunAsNode(process.env), ...extraEnv }) + 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'], + env + }) + session.child = child + child.once('error', (error) => + rejectPromise( + new RuntimeClientError( + 'internal', + `Could not launch \`${command}\`. Is it installed and on PATH? (${ + error instanceof Error ? error.message : String(error) + })` + ) + ) + ) + child.once('exit', (code) => { + session.child = null + if (code === 0) { + resolvePromise() + return + } + rejectPromise( + new RuntimeClientError( + 'internal', + `\`${command} ${args.join(' ')}\` exited with code ${code ?? 'null'}.` + ) + ) + }) + }) +} + +async function cleanupClaudeLoginArtifacts( + configDir: string, + legacyCredentials: string | null, + restoreLegacyCredentials: boolean +): Promise { + const errors: unknown[] = [] + if (process.platform === 'darwin') { + try { + await deleteActiveClaudeKeychainCredentialsStrict(configDir) + } catch (error) { + errors.push(error) + } + if (restoreLegacyCredentials) { + try { + await (legacyCredentials + ? writeActiveClaudeKeychainCredentials(legacyCredentials) + : deleteActiveClaudeKeychainCredentialsStrict()) + } catch (error) { + errors.push(error) + } + } + } + try { + rmSync(configDir, { recursive: true, force: true }) + } catch (error) { + errors.push(error) + } + if (errors.length > 0) { + throw new AggregateError(errors, 'Failed to clean up Claude login artifacts.') + } +} + +/** Logs into a Claude account in a temp config dir, then registers it with the local runtime. */ +async function addClaudeAccount({ client, json }: HandlerContext): Promise { + const configDir = mkdtempSync(join(tmpdir(), 'orca-account-add-claude-')) + const session: InteractiveLoginSession = { + child: null, + registering: false, + terminationPromise: null + } + let legacyCredentials: string | null = null + let restoreLegacyCredentials = false + const result = await withInteractiveLoginCleanup( + session, + async () => { + await cleanupClaudeLoginArtifacts(configDir, legacyCredentials, restoreLegacyCredentials) + }, + async () => { + if (process.platform === 'darwin') { + legacyCredentials = await readActiveClaudeKeychainCredentialsStrict() + restoreLegacyCredentials = true + } + await runAgentLoginInTerminal( + 'claude', + ['auth', 'login', '--claudeai'], + { + CLAUDE_CONFIG_DIR: configDir + }, + json, + session + ) + session.registering = true + return client.call('accounts.addClaudeFromConfigDir', { + configDir, + ...(process.platform === 'darwin' + ? { + previousLegacyCredentialsSha256: legacyCredentials + ? createHash('sha256').update(legacyCredentials).digest('hex') + : null + } + : {}) + }) + } + ) + printResult(result, json, (state) => formatAccountsBlock('Claude', state)) +} + +/** Logs into a Codex account in a temp CODEX_HOME, then registers it with the local runtime. */ +async function addCodexAccount({ client, json }: HandlerContext): Promise { + const codexHome = mkdtempSync(join(tmpdir(), 'orca-account-add-codex-')) + const session: InteractiveLoginSession = { + child: null, + registering: false, + terminationPromise: null + } + const result = await withInteractiveLoginCleanup( + session, + async () => { + rmSync(codexHome, { recursive: true, force: true }) + }, + async () => { + // Why: plain OAuth binds a loopback callback the user's browser cannot reach + // on a headless/SSH host; device auth is explicitly designed for this flow. + await runAgentLoginInTerminal( + 'codex', + ['login', '--device-auth'], + { CODEX_HOME: codexHome }, + json, + session + ) + session.registering = true + return client.call('accounts.addCodexFromHome', { + sourceHome: codexHome + }) + } + ) + printResult(result, json, (state) => formatAccountsBlock('Codex', state)) +} + +/** + * Rejects the runtime-selector flags instead of ignoring them. shouldIgnoreRemoteSelection + * pins account commands to the local runtime, so honoring `--environment homelab` + * silently would target the laptop rather than the host the user named — the exact + * mistake this feature exists to avoid. A `--help` note does not reach someone who + * already typed the flag. + */ +function rejectRemoteSelectionFlags(ctx: HandlerContext, command: string): void { + for (const flag of ['environment', 'pairing-code']) { + if (ctx.flags.has(flag)) { + throw new RuntimeClientError( + 'invalid_argument', + `\`--${flag}\` does not retarget \`${command}\`. Run it on the host whose accounts you want to manage.` + ) + } + } +} + +async function assertAccountImportSupported({ client }: HandlerContext): Promise { + const status = await client.call('status.get') + if (!status.result.capabilities?.includes(ACCOUNT_IMPORT_RUNTIME_CAPABILITY)) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'The running Orca runtime is too old to add accounts from the CLI. Update or restart Orca and try again.' + ) + } +} + +/** CLI handlers for `orca account add [--agent claude|codex]` and `orca account list`. */ +export const ACCOUNT_HANDLERS: Record = { + 'account add': async (ctx) => { + const agentFlag = ctx.flags.get('agent') + // Why: a valueless `--agent` parses as boolean true; defaulting it to claude + // would silently run a full OAuth login for the provider the user did not ask for. + if (agentFlag !== undefined && typeof agentFlag !== 'string') { + throw new RuntimeClientError( + 'invalid_argument', + 'Missing a value for --agent. Use `--agent claude` or `--agent codex`.' + ) + } + const agent = agentFlag ?? 'claude' + if (agent !== 'claude' && agent !== 'codex') { + throw new RuntimeClientError( + 'invalid_argument', + `Unsupported --agent "${agent}". Use "claude" or "codex".` + ) + } + rejectRemoteSelectionFlags(ctx, 'orca account add') + // Why: fail on runtime version skew before burning a full OAuth round trip. + await assertAccountImportSupported(ctx) + await ctx.client.call('accounts.list', { refreshUsage: false }) + await (agent === 'claude' ? addClaudeAccount(ctx) : addCodexAccount(ctx)) + }, + 'account list': async (ctx) => { + rejectRemoteSelectionFlags(ctx, 'orca account list') + const { client, json } = ctx + // Why: this command renders no usage numbers, so skip the forced provider + // refresh — it is one serial network round-trip per managed account. + const result = await client.call('accounts.list', { + refreshUsage: false + }) + printResult( + result, + json, + (snapshot) => + `${formatAccountsBlock('Claude', snapshot.claude)}\n\n${formatAccountsBlock('Codex', snapshot.codex)}` + ) + } +} diff --git a/src/cli/handlers/interactive-login-interruption.test.ts b/src/cli/handlers/interactive-login-interruption.test.ts new file mode 100644 index 00000000000..8bd3ef98466 --- /dev/null +++ b/src/cli/handlers/interactive-login-interruption.test.ts @@ -0,0 +1,121 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + LOGIN_PROCESS_CLOSE_FALLBACK_MS, + LOGIN_PROCESS_POSIX_GRACE_MS, + terminateInteractiveLoginProcess, + terminateWindowsLoginProcessTree, + WINDOWS_LOGIN_TREE_KILL_TIMEOUT_MS +} from './interactive-login-interruption' + +function loginChild(pid = 4321): ChildProcess & EventEmitter { + return Object.assign(new EventEmitter(), { + kill: vi.fn(), + pid + }) as unknown as ChildProcess & EventEmitter +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('terminateInteractiveLoginProcess', () => { + it('runs bounded taskkill /T /F without a shell', async () => { + const execFileImpl = vi.fn( + ( + _command: string, + _args: readonly string[], + _options: { timeout?: number; windowsHide?: boolean }, + callback: (error: Error | null) => void + ) => callback(null) + ) + + await terminateWindowsLoginProcessTree(4321, execFileImpl as never) + + expect(execFileImpl).toHaveBeenCalledWith( + 'taskkill', + ['/pid', '4321', '/T', '/F'], + { timeout: WINDOWS_LOGIN_TREE_KILL_TIMEOUT_MS, windowsHide: true }, + expect.any(Function) + ) + }) + + it('waits for both Windows tree termination and physical wrapper close', async () => { + const child = loginChild() + let finishTreeKill: (() => void) | undefined + const killWindowsTree = vi.fn( + () => + new Promise((resolve) => { + finishTreeKill = resolve + }) + ) + let terminated = false + + const pending = terminateInteractiveLoginProcess(child, 'SIGTERM', { + platform: 'win32', + killWindowsTree + }).then(() => { + terminated = true + }) + + expect(killWindowsTree).toHaveBeenCalledWith(4321) + child.emit('close', 1) + await Promise.resolve() + expect(terminated).toBe(false) + + finishTreeKill?.() + await pending + expect(child.kill).not.toHaveBeenCalled() + }) + + it('bounds Windows close waits and falls back to the direct wrapper', async () => { + vi.useFakeTimers() + const child = loginChild() + + const pending = terminateInteractiveLoginProcess(child, 'SIGINT', { + platform: 'win32', + killWindowsTree: vi.fn().mockResolvedValue(undefined) + }) + await vi.advanceTimersByTimeAsync(LOGIN_PROCESS_CLOSE_FALLBACK_MS) + expect(child.kill).toHaveBeenCalledOnce() + expect(child.kill).toHaveBeenCalledWith(undefined) + + await vi.advanceTimersByTimeAsync(LOGIN_PROCESS_CLOSE_FALLBACK_MS) + await pending + }) + + it('forwards the interrupt signal to a direct POSIX child and awaits close', async () => { + const child = loginChild() + let terminated = false + + const pending = terminateInteractiveLoginProcess(child, 'SIGHUP', { + platform: 'linux' + }).then(() => { + terminated = true + }) + + expect(child.kill).toHaveBeenCalledWith('SIGHUP') + await Promise.resolve() + expect(terminated).toBe(false) + + child.emit('close', 1) + await pending + expect(child.kill).toHaveBeenCalledOnce() + }) + + it('force-kills an unresponsive POSIX child before the final bounded wait', async () => { + vi.useFakeTimers() + const child = loginChild() + + const pending = terminateInteractiveLoginProcess(child, 'SIGINT', { + platform: 'darwin' + }) + await vi.advanceTimersByTimeAsync(LOGIN_PROCESS_POSIX_GRACE_MS) + expect(child.kill).toHaveBeenNthCalledWith(1, 'SIGINT') + expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL') + + await vi.advanceTimersByTimeAsync(LOGIN_PROCESS_CLOSE_FALLBACK_MS) + await pending + }) +}) diff --git a/src/cli/handlers/interactive-login-interruption.ts b/src/cli/handlers/interactive-login-interruption.ts new file mode 100644 index 00000000000..4ef1a06c2db --- /dev/null +++ b/src/cli/handlers/interactive-login-interruption.ts @@ -0,0 +1,184 @@ +import { execFile, type ChildProcess } from 'node:child_process' + +export const LOGIN_PROCESS_POSIX_GRACE_MS = 5_000 +export const LOGIN_PROCESS_CLOSE_FALLBACK_MS = 1_000 +export const WINDOWS_LOGIN_TREE_KILL_TIMEOUT_MS = 5_000 + +type WindowsTreeKiller = (rootPid: number) => Promise + +type TerminationDependencies = { + platform?: NodeJS.Platform + killWindowsTree?: WindowsTreeKiller + posixGraceMs?: number + closeFallbackMs?: number +} + +export type InteractiveLoginSession = { + child: ChildProcess | null + registering: boolean + terminationPromise: Promise | null +} + +const INTERRUPT_EXIT_CODES: Record = { SIGHUP: 129, SIGINT: 130, SIGTERM: 143 } +const INTERRUPT_SIGNALS = Object.keys(INTERRUPT_EXIT_CODES) as NodeJS.Signals[] + +function waitForClose(closePromise: Promise, timeoutMs: number): Promise { + let timer: NodeJS.Timeout | undefined + return Promise.race([ + closePromise.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs) + }) + ]).finally(() => { + if (timer) { + clearTimeout(timer) + } + }) +} + +function killDirectChild(child: ChildProcess, signal?: NodeJS.Signals): void { + try { + child.kill(signal) + } catch { + // The child may close between the timeout and fallback signal. + } +} + +export function terminateWindowsLoginProcessTree( + rootPid: number, + execFileImpl: typeof execFile = execFile +): Promise { + if (!Number.isInteger(rootPid) || rootPid <= 0) { + return Promise.resolve() + } + return new Promise((resolve) => { + execFileImpl( + 'taskkill', + ['/pid', String(rootPid), '/T', '/F'], + { timeout: WINDOWS_LOGIN_TREE_KILL_TIMEOUT_MS, windowsHide: true }, + () => resolve() + ) + }) +} + +/** + * Stops an interrupted interactive login before its credential directory is removed. + * The final close wait is bounded because a broken child handle must not hang Ctrl-C. + */ +export async function terminateInteractiveLoginProcess( + child: ChildProcess, + signal: NodeJS.Signals, + deps: TerminationDependencies = {} +): Promise { + let closed = false + let resolveClose: (() => void) | undefined + const onClose = (): void => { + closed = true + resolveClose?.() + } + const closePromise = new Promise((resolve) => { + resolveClose = resolve + }) + child.once('close', onClose) + + const platform = deps.platform ?? process.platform + const closeFallbackMs = deps.closeFallbackMs ?? LOGIN_PROCESS_CLOSE_FALLBACK_MS + try { + if (platform === 'win32') { + if (child.pid) { + const killTree = deps.killWindowsTree ?? terminateWindowsLoginProcessTree + await killTree(child.pid).catch(() => {}) + if (closed || (await waitForClose(closePromise, closeFallbackMs))) { + return + } + } + killDirectChild(child) + if (closed) { + return + } + await waitForClose(closePromise, closeFallbackMs) + return + } + + killDirectChild(child, signal) + const posixGraceMs = deps.posixGraceMs ?? LOGIN_PROCESS_POSIX_GRACE_MS + if (closed || (await waitForClose(closePromise, posixGraceMs))) { + return + } + killDirectChild(child, 'SIGKILL') + await waitForClose(closePromise, closeFallbackMs) + } finally { + child.off('close', onClose) + } +} + +/** + * Runs an account add with signal-safe cleanup and primary-error precedence. + */ +export async function withInteractiveLoginCleanup( + session: InteractiveLoginSession, + cleanup: () => Promise, + add: () => Promise +): Promise { + let cleanupPromise: Promise | null = null + let cleanupFailureReported = false + const cleanupOnce = (): Promise => + (cleanupPromise ??= (async () => { + await session.terminationPromise + await cleanup() + })()) + const reportCleanupFailure = (error: unknown): void => { + if (cleanupFailureReported) { + return + } + cleanupFailureReported = true + console.warn('[account] Failed to clean up the temporary login directory:', error) + } + const onSignal = (signal: NodeJS.Signals): void => { + if (session.child) { + session.terminationPromise ??= terminateInteractiveLoginProcess(session.child, signal) + } + if (session.registering) { + console.warn( + '[account] Interrupted after sign-in completed; the account may still have been registered. Run `orca account list` to check.' + ) + } + void cleanupOnce() + .catch(reportCleanupFailure) + .finally(() => process.exit(INTERRUPT_EXIT_CODES[signal] ?? 1)) + } + // Why: repeated signals must keep awaiting the same cleanup instead of restoring Node defaults. + for (const signal of INTERRUPT_SIGNALS) { + process.on(signal, onSignal) + } + let addFailed = false + let addError: unknown + let addResult: { value: T } | null = null + try { + addResult = { value: await add() } + } catch (error) { + addFailed = true + addError = error + } + let cleanupError: unknown + try { + await cleanupOnce() + } catch (error) { + cleanupError = error + } finally { + for (const signal of INTERRUPT_SIGNALS) { + process.off(signal, onSignal) + } + } + if (addFailed) { + if (cleanupError !== undefined) { + // Why: retain the error that explains the failed account add. + reportCleanupFailure(cleanupError) + } + throw addError + } + if (cleanupError !== undefined) { + throw cleanupError + } + return addResult!.value +} diff --git a/src/cli/help.ts b/src/cli/help.ts index c9d98603c63..d85a318cb39 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -18,6 +18,10 @@ Diagnostics: Agent Discovery: agent-context Print the machine-readable command schema for agents +Accounts: + account add Add a managed Claude or Codex account on this Orca host + account list List managed Claude and Codex accounts on this Orca host + Skills: skills list List version-matched skill guides bundled with this Orca CLI skills get Print a version-matched skill guide as Markdown @@ -214,6 +218,8 @@ Common Commands: orca status [--json] orca diagnostics memory [--json] orca agent-context [--json] + orca account add [--agent claude|codex] [--json] + orca account list [--json] orca environment add --name --pairing-code [--json] orca environment list [--json] orca environment show --environment [--json] @@ -486,6 +492,11 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string { if (command === 'orchestration task-create' && flag === 'display-name') { return '--display-name UI label shown for dispatched worker rows' } + // Why: the shared --agent help describes launching a TUI agent in a terminal, + // which is the wrong meaning here — this selects the account provider. + if (command === 'account add' && flag === 'agent') { + return '--agent Account provider: claude or codex (default claude)' + } if (flag === 'key' && command === 'computer hotkey') { return '--key Modifier chord with one key, e.g. CmdOrCtrl+A' } diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 10b1205fe39..6eb064bee28 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -306,6 +306,20 @@ describe('orca root help', () => { logSpy.mockRestore() }) + it('advertises host-local account management', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main([], '/tmp/repo') + + expect(logSpy.mock.calls.flat().join('\n')).toContain( + 'account add Add a managed Claude or Codex account on this Orca host' + ) + expect(logSpy.mock.calls.flat().join('\n')).toContain( + 'account list List managed Claude and Codex accounts on this Orca host' + ) + logSpy.mockRestore() + }) + it('advertises computer-use capabilities discovery', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) diff --git a/src/cli/index.ts b/src/cli/index.ts index 1ffe55a4702..cf4610cc850 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -21,6 +21,7 @@ const COMMAND_PATHS = COMMAND_SPECS.flatMap((spec) => specPaths(spec)) function shouldIgnoreRemoteSelection(commandPath: string[]): boolean { return ( + commandPath[0] === 'account' || commandPath[0] === 'environment' || commandPath[0] === 'serve' || commandPath[0] === 'agent' || diff --git a/src/cli/main-module-bundle-parity.test.ts b/src/cli/main-module-bundle-parity.test.ts new file mode 100644 index 00000000000..8d00749f65b --- /dev/null +++ b/src/cli/main-module-bundle-parity.test.ts @@ -0,0 +1,58 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const REPO_ROOT = resolve(__dirname, '..', '..') +const CLI_ROOT = join(REPO_ROOT, 'src', 'cli') + +function listCliSourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) { + return listCliSourceFiles(path) + } + return entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') + ? [path] + : [] + }) +} + +// Why: `import type` is erased by tsc, so it needs no emitted module at runtime. +const VALUE_IMPORT_FROM_MAIN = /(? { + const source = readFileSync(file, 'utf-8') + return [...source.matchAll(VALUE_IMPORT_FROM_MAIN)].map((match) => ({ + file: file.slice(REPO_ROOT.length + 1), + module: match[1] + })) + }) +} + +function findElectronViteMainEntries(): Set { + const config = readFileSync(join(REPO_ROOT, 'electron.vite.config.ts'), 'utf-8') + return new Set( + // Why: entries wrap across lines once the path is long, so allow whitespace. + [...config.matchAll(/resolve\(\s*'src\/main\/([^']+)\.ts'\s*\)/g)].map((match) => match[1]) + ) +} + +describe('CLI imports of main-process modules', () => { + // Why: electron-vite cleans out/main and emits only its declared entries, so a + // `src/main/*` module the CLI imports but the config omits is deleted by the + // build that runs after `build:cli` — the command then dies at require time + // with "Cannot find module". Nothing else catches this before packaging. + it('has an electron-vite entry for every main module the CLI imports', () => { + const entries = findElectronViteMainEntries() + const missing = findMainImports().filter(({ module }) => !entries.has(module)) + + expect(missing).toEqual([]) + }) + + it('finds the imports it is meant to guard', () => { + // Why: a broken matcher would make the guard above vacuously pass. + expect(findMainImports().length).toBeGreaterThanOrEqual(4) + expect(findElectronViteMainEntries().size).toBeGreaterThanOrEqual(4) + }) +}) diff --git a/src/cli/specs/account.test.ts b/src/cli/specs/account.test.ts new file mode 100644 index 00000000000..7e285cf3b97 --- /dev/null +++ b/src/cli/specs/account.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { ACCOUNT_COMMAND_SPECS } from './account' +import { effectiveAllowedFlags } from '../args' +import { formatCommandHelp } from '../help' + +function spec(path: string): (typeof ACCOUNT_COMMAND_SPECS)[number] { + const found = ACCOUNT_COMMAND_SPECS.find((entry) => entry.path.join(' ') === path) + if (!found) { + throw new Error(`Missing account spec: ${path}`) + } + return found +} + +describe('account command specs', () => { + it('does not accept or advertise browser page targeting', () => { + for (const entry of ACCOUNT_COMMAND_SPECS) { + expect(effectiveAllowedFlags(entry)).not.toContain('page') + expect(formatCommandHelp(entry)).not.toContain('--page') + } + }) + + // Why: named for what it asserts — the rendered Options block, not the `usage` + // string, which this test never reads. + it('renders --json and --help in its Options block', () => { + for (const entry of ACCOUNT_COMMAND_SPECS) { + const help = formatCommandHelp(entry) + expect(help).toContain('--json') + expect(help).toContain('--help') + } + }) + + it('describes --agent as the account provider, not a terminal agent', () => { + const help = formatCommandHelp(spec('account add')) + + expect(help).toContain('Account provider: claude or codex (default claude)') + expect(help).not.toContain('TUI agent') + }) + + it('aligns the --agent description with the global flag descriptions', () => { + const descriptionColumn = (help: string, flag: string): number => { + const line = help.split('\n').find((entry) => entry.startsWith(` --${flag}`)) + const match = line?.match(/^(\s*--\S+(?: <[^>]+>)?)(\s+)\S/) + if (!match) { + throw new Error(`No description found for --${flag}`) + } + return match[1].length + match[2].length + } + const help = formatCommandHelp(spec('account add')) + + expect(descriptionColumn(help, 'agent')).toBe(descriptionColumn(help, 'json')) + }) +}) diff --git a/src/cli/specs/account.ts b/src/cli/specs/account.ts new file mode 100644 index 00000000000..69dd0896e8d --- /dev/null +++ b/src/cli/specs/account.ts @@ -0,0 +1,32 @@ +import { GLOBAL_FLAGS, type CommandSpec } from '../args' + +// Why: the desktop "Add account" button is disabled when the UI drives a remote +// runtime (a headless server). These commands run the interactive agent login +// (`claude login` / `codex login`) in the caller's own terminal on the host and +// register the captured account with the local runtime, giving headless hosts a +// way to manage Claude and Codex accounts. +export const ACCOUNT_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['account', 'add'], + summary: 'Add a managed Claude or Codex account by signing in on this Orca host', + usage: 'orca account add [--agent claude|codex] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'agent'], + notes: [ + 'Runs the agent login (`claude login` / `codex login`) in this terminal, then registers the account with the local Orca runtime.', + 'Codex uses device authorization so the browser can complete sign-in from a different machine.', + 'Sign in with the account you want to add (e.g. use a private/incognito browser window for a second account).', + '--agent defaults to claude. Requires the Orca runtime to be running on this machine.' + ], + examples: ['orca account add', 'orca account add --agent codex'] + }, + { + path: ['account', 'list'], + summary: 'List managed Claude and Codex accounts on this Orca host', + usage: 'orca account list [--json]', + allowedFlags: [...GLOBAL_FLAGS], + notes: [ + 'Lists the accounts on this machine. `--environment` / `--pairing-code` are rejected rather than ignored; run it on the host whose accounts you want to see.' + ], + examples: ['orca account list'] + } +] diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index 73df0dd4011..e779aa3b748 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -1,4 +1,5 @@ import type { CommandSpec } from '../args' +import { ACCOUNT_COMMAND_SPECS } from './account' import { BROWSER_ADVANCED_COMMAND_SPECS } from './browser-advanced' import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic' import { AUTOMATION_COMMAND_SPECS } from './automations' @@ -18,6 +19,7 @@ import { SKILL_COMMAND_SPECS } from './skills' export const COMMAND_SPECS: CommandSpec[] = [ ...CORE_COMMAND_SPECS, + ...ACCOUNT_COMMAND_SPECS, ...PROJECT_COMMAND_SPECS, ...FILE_COMMAND_SPECS, ...AUTOMATION_COMMAND_SPECS, diff --git a/src/main/claude-accounts/service.test.ts b/src/main/claude-accounts/service.test.ts index dfbcd2322fe..6572bae7fca 100644 --- a/src/main/claude-accounts/service.test.ts +++ b/src/main/claude-accounts/service.test.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines -- test suite covers Claude capture and rollback edge cases */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createHash } from 'node:crypto' import { EventEmitter } from 'node:events' import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -15,9 +16,11 @@ import { writeManagedClaudeKeychainCredentials } from './keychain' +const CLAUDE_SERVICE_TEST_ROOT = join(tmpdir(), 'orca-claude-service-test') + vi.mock('electron', () => ({ app: { - getPath: () => '/tmp/orca-claude-service-test' + getPath: () => CLAUDE_SERVICE_TEST_ROOT } })) @@ -171,7 +174,7 @@ describe('ClaudeAccountService credential capture', () => { it('restores previous managed auth when reauth materialization fails', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') mkdirSync(managedAuthPath, { recursive: true }) @@ -241,7 +244,7 @@ describe('ClaudeAccountService credential capture', () => { it('restores settings without rematerializing when managed-auth rollback write fails', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') mkdirSync(managedAuthPath, { recursive: true }) @@ -315,7 +318,7 @@ describe('ClaudeAccountService credential capture', () => { it('restores oauth metadata when new credential write and credential rollback fail', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') mkdirSync(managedAuthPath, { recursive: true }) @@ -393,7 +396,7 @@ describe('ClaudeAccountService credential capture', () => { it('restores old metadata when rollback restores credentials but oauth restore fails', async () => { setPlatform('linux') const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') const oauthPath = join(managedAuthPath, 'oauth-account.json') @@ -465,7 +468,7 @@ describe('ClaudeAccountService credential capture', () => { it('refreshes rate limits without recaching a removed active account', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') mkdirSync(managedAuthPath, { recursive: true }) @@ -523,7 +526,7 @@ describe('ClaudeAccountService credential capture', () => { it('evicts inactive rate-limit cache after successful reauth', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const managedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') mkdirSync(managedAuthPath, { recursive: true }) @@ -593,7 +596,7 @@ describe('ClaudeAccountService credential capture', () => { it('adds an account without switching the active Claude auth while PTYs are live', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') mkdirSync(hostAuthPath, { recursive: true }) @@ -675,9 +678,93 @@ describe('ClaudeAccountService credential capture', () => { ) }) + it('reports the original add failure and still removes managed auth when rollback rematerialization fails', async () => { + // Why: this is the desktop add path. Previously the rollback's rematerialize + // was unguarded, so when it threw it replaced the real add error and skipped + // safeRemoveManagedAuth, leaking the throwaway auth dir. + setPlatform('linux') + tempDir = CLAUDE_SERVICE_TEST_ROOT + rmSync(tempDir, { recursive: true, force: true }) + const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') + mkdirSync(hostAuthPath, { recursive: true }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + let settings = { + claudeManagedAccounts: [ + { + id: 'host-account', + email: 'host@example.com', + managedAuthPath: hostAuthPath, + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeClaudeManagedAccountId: 'host-account', + activeClaudeManagedAccountIdsByRuntime: { host: 'host-account', wsl: { Ubuntu: null } } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + syncForCurrentSelection: vi.fn(async () => {}), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => { + throw new Error('rematerialize failed') + }) + } + const rateLimits = { + evictInactiveClaudeCache: vi.fn(), + refreshForClaudeAccountChange: vi.fn(async () => ({ accounts: [], activeAccountId: null })) + } + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + store as never, + rateLimits as never, + runtimeAuth as never + ) + ;( + service as unknown as { + runClaudeLoginAndCapture(): Promise<{ + credentialsJson: string + oauthAccount: unknown + identity: { email: string; organizationUuid: null; organizationName: null } + }> + } + ).runClaudeLoginAndCapture = vi.fn(async () => ({ + credentialsJson: '{"new":true}\n', + oauthAccount: null, + identity: { email: 'new@example.com', organizationUuid: null, organizationName: null } + })) + ;(service as unknown as { writeManagedAuth(): Promise }).writeManagedAuth = vi.fn( + async () => { + throw new Error('managed auth write failed') + } + ) + + await expect(service.addAccount({ runtime: 'host' })).rejects.toThrow( + 'managed auth write failed' + ) + + expect(runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() + expect(settings.claudeManagedAccounts).toHaveLength(1) + // Why: the throwaway account directory must be gone even though rollback threw. + expect(readdirSync(join(tempDir, 'claude-accounts'))).toEqual(['host-account']) + warn.mockRestore() + }) + it('rejects adding a Claude account whose identity already exists', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const existingAuthPath = join(tempDir, 'claude-accounts', 'existing-account', 'auth') mkdirSync(existingAuthPath, { recursive: true }) @@ -756,7 +843,7 @@ describe('ClaudeAccountService credential capture', () => { it('adds a Claude account with the same email under a different organization', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const existingAuthPath = join(tempDir, 'claude-accounts', 'existing-account', 'auth') mkdirSync(existingAuthPath, { recursive: true }) @@ -830,7 +917,7 @@ describe('ClaudeAccountService credential capture', () => { it('switches the active Claude account while PTYs are live', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const firstAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') const secondAuthPath = join(tempDir, 'claude-accounts', 'account-2', 'auth') @@ -912,7 +999,7 @@ describe('ClaudeAccountService credential capture', () => { it('restores the previous selection when a Claude account switch fails', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const firstAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') const secondAuthPath = join(tempDir, 'claude-accounts', 'account-2', 'auth') @@ -988,7 +1075,7 @@ describe('ClaudeAccountService credential capture', () => { it('selects a WSL account without changing the Windows active account', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') @@ -1075,7 +1162,7 @@ describe('ClaudeAccountService credential capture', () => { it('rejects selecting a WSL account for the Windows target', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') mkdirSync(wslAuthPath, { recursive: true }) @@ -1126,7 +1213,7 @@ describe('ClaudeAccountService credential capture', () => { it('removes a WSL account without clearing the Windows active account', async () => { setPlatform('linux') - tempDir = '/tmp/orca-claude-service-test' + tempDir = CLAUDE_SERVICE_TEST_ROOT rmSync(tempDir, { recursive: true, force: true }) const hostAuthPath = join(tempDir, 'claude-accounts', 'host-account', 'auth') const wslAuthPath = join(tempDir, 'claude-accounts', 'wsl-account', 'auth') @@ -1730,3 +1817,218 @@ describe('ClaudeAccountService credential capture', () => { } }) }) + +describe('ClaudeAccountService.addAccountFromConfigDir', () => { + const managedRoot = CLAUDE_SERVICE_TEST_ROOT + let sourceDir: string | null = null + + beforeEach(() => { + setPlatform('linux') + rmSync(managedRoot, { recursive: true, force: true }) + sourceDir = null + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockReset() + vi.mocked(writeManagedClaudeKeychainCredentials).mockReset().mockResolvedValue() + }) + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + rmSync(managedRoot, { recursive: true, force: true }) + if (sourceDir) { + rmSync(sourceDir, { recursive: true, force: true }) + } + }) + + function makeDeps() { + let settings = { + claudeManagedAccounts: [] as ClaudeManagedAccount[], + activeClaudeManagedAccountId: null as string | null, + activeClaudeManagedAccountIdsByRuntime: { host: null as string | null, wsl: {} } + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + } + const rateLimits = { evictInactiveClaudeCache: vi.fn() } + const runtimeAuth = { + clearLastWrittenCredentialsJson: vi.fn(), + forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {}) + } + return { store, rateLimits, runtimeAuth, getSettings: () => settings } + } + + it('registers a managed account by capturing an authenticated config dir', async () => { + sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-')) + writeFileSync( + join(sourceDir, '.credentials.json'), + '{"claudeAiOauth":{"accessToken":"tok"}}\n', + 'utf-8' + ) + writeFileSync( + join(sourceDir, '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: 'new@example.com' } }), + 'utf-8' + ) + + const deps = makeDeps() + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + deps.store as never, + deps.rateLimits as never, + deps.runtimeAuth as never + ) + // Why: avoid spawning a real `claude auth status` subprocess in the test. + ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( + async () => '{"email":"new@example.com"}' + ) + + const result = await service.addAccountFromConfigDir(sourceDir) + + const accounts = deps.getSettings().claudeManagedAccounts + expect(accounts).toHaveLength(1) + expect(accounts[0].email).toBe('new@example.com') + expect(result.accounts[0]?.email).toBe('new@example.com') + expect(readFileSync(join(accounts[0].managedAuthPath, '.credentials.json'), 'utf-8')).toBe( + '{"claudeAiOauth":{"accessToken":"tok"}}\n' + ) + expect(deps.runtimeAuth.clearLastWrittenCredentialsJson).toHaveBeenCalledWith(accounts[0].id) + }) + + it('captures only the config-scoped macOS Keychain credential', async () => { + setPlatform('darwin') + sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-keychain-')) + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (configDir) => + configDir ? '{"claudeAiOauth":{"accessToken":"scoped"}}' : 'legacy-credentials' + ) + const deps = makeDeps() + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + deps.store as never, + deps.rateLimits as never, + deps.runtimeAuth as never + ) + ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( + async () => '{"email":"new@example.com"}' + ) + + await service.addAccountFromConfigDir(sourceDir) + + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(sourceDir) + expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith( + expect.any(String), + '{"claudeAiOauth":{"accessToken":"scoped"}}' + ) + }) + + it('does not mistake an unchanged legacy Keychain credential for the temp login', async () => { + setPlatform('darwin') + sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-keychain-empty-')) + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (configDir) => + configDir ? null : 'legacy-credentials' + ) + const deps = makeDeps() + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + deps.store as never, + deps.rateLimits as never, + deps.runtimeAuth as never + ) + ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( + async () => '{"email":"existing@example.com"}' + ) + + await expect(service.addAccountFromConfigDir(sourceDir)).rejects.toThrow( + 'no OAuth credentials were captured' + ) + expect(deps.getSettings().claudeManagedAccounts).toHaveLength(0) + }) + + it('captures a legacy Keychain credential that changed after login began', async () => { + setPlatform('darwin') + sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-keychain-legacy-')) + const previousCredentials = '{"claudeAiOauth":{"accessToken":"previous"}}' + const newCredentials = '{"claudeAiOauth":{"accessToken":"new","email":"new@example.com"}}' + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockImplementation(async (configDir) => + configDir ? null : newCredentials + ) + const deps = makeDeps() + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + deps.store as never, + deps.rateLimits as never, + deps.runtimeAuth as never + ) + ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( + async () => '{"email":"new@example.com"}' + ) + + await service.addAccountFromConfigDir(sourceDir, { + previousLegacyCredentialsSha256: createHash('sha256') + .update(previousCredentials) + .digest('hex') + }) + + expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith( + expect.any(String), + newCredentials + ) + expect(deps.getSettings().claudeManagedAccounts[0]?.email).toBe('new@example.com') + }) + + it('still registers when the daemon cannot spawn `claude auth status`', async () => { + // Why: `allowFailure` covers a non-zero exit but not a spawn error, so a daemon + // started with a minimal PATH would hard-fail an add the user already signed in for. + sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-nostatus-')) + writeFileSync( + join(sourceDir, '.credentials.json'), + '{"claudeAiOauth":{"accessToken":"tok"}}\n', + 'utf-8' + ) + writeFileSync( + join(sourceDir, '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: 'new@example.com' } }), + 'utf-8' + ) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const deps = makeDeps() + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + deps.store as never, + deps.rateLimits as never, + deps.runtimeAuth as never + ) + ;(service as unknown as { runClaudeCommand: () => Promise }).runClaudeCommand = vi.fn( + async () => { + throw Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' }) + } + ) + + const result = await service.addAccountFromConfigDir(sourceDir) + + expect(result.accounts[0]?.email).toBe('new@example.com') + expect(deps.getSettings().claudeManagedAccounts).toHaveLength(1) + warn.mockRestore() + }) + + it('rejects and rolls back when the config dir has no credentials', async () => { + sourceDir = mkdtempSync(join(tmpdir(), 'orca-claude-source-empty-')) + const deps = makeDeps() + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + deps.store as never, + deps.rateLimits as never, + deps.runtimeAuth as never + ) + + await expect(service.addAccountFromConfigDir(sourceDir)).rejects.toThrow( + /No Claude credentials found/ + ) + expect(deps.getSettings().claudeManagedAccounts).toHaveLength(0) + expect(deps.runtimeAuth.forceMaterializeCurrentSelectionForRollback).toHaveBeenCalled() + }) +}) diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 42601c6ab56..3e3a8ec5f47 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines -- Why: Claude managed accounts need one audited owner for login, credential capture, Keychain storage, selection, and rate-limit refresh. */ -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { execFileSync, spawn } from 'node:child_process' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -76,6 +76,10 @@ export type ClaudeAccountAddTarget = { wslDistro?: string | null } +export type ClaudeAccountImportOptions = ClaudeAccountAddTarget & { + previousLegacyCredentialsSha256?: string | null +} + type ManagedClaudeAuthLocation = { managedAuthPath: string managedAuthRuntime: 'host' | 'wsl' @@ -83,6 +87,8 @@ type ManagedClaudeAuthLocation = { wslLinuxAuthPath: string | null } +class DuplicateClaudeAccountError extends Error {} + function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` } @@ -106,6 +112,19 @@ export class ClaudeAccountService { return this.serializeMutation(() => this.doAddAccount(target)) } + /** + * Adds a managed Claude account from an already-authenticated `CLAUDE_CONFIG_DIR` + * instead of driving the interactive browser login here. Enables the + * `orca account add` CLI to run `claude login` in the user's own terminal on a + * headless host, then register the captured credentials without a desktop GUI. + */ + async addAccountFromConfigDir( + configDir: string, + options?: ClaudeAccountImportOptions + ): Promise { + return this.serializeMutation(() => this.doAddAccountFromConfigDir(configDir, options)) + } + async reauthenticateAccount(accountId: string): Promise { return this.serializeMutation(() => this.doReauthenticateAccount(accountId)) } @@ -140,67 +159,173 @@ export class ClaudeAccountService { ): Promise { const accountId = randomUUID() const managedAuth = this.createManagedAuthDir(accountId, target) - const { managedAuthPath } = managedAuth const previousSettings = this.store.getSettings() - let duplicateIdentityFound = false - try { const captured = await this.runClaudeLoginAndCapture(managedAuth) - if (!captured.identity.email) { - throw new Error('Claude login completed, but Orca could not resolve the account email.') - } - // Why: duplicate rows confuse account selection and rate-limit tracking; - // the per-row Re-authenticate action already refreshes credentials. - if ( - findDuplicateClaudeAccount(previousSettings.claudeManagedAccounts, { - email: captured.identity.email, - organizationUuid: captured.identity.organizationUuid, - managedAuthRuntime: managedAuth.managedAuthRuntime, - wslDistro: managedAuth.wslDistro - }) - ) { - duplicateIdentityFound = true - throw new Error('This Claude account is already added.') - } - await this.writeManagedAuth(accountId, managedAuthPath, captured) - - const now = Date.now() - const account: ClaudeManagedAccount = { - id: accountId, - email: captured.identity.email, - managedAuthPath, - managedAuthRuntime: managedAuth.managedAuthRuntime, - wslDistro: managedAuth.wslDistro, - wslLinuxAuthPath: managedAuth.wslLinuxAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: captured.identity.organizationUuid, - organizationName: captured.identity.organizationName, - createdAt: now, - updatedAt: now, - lastAuthenticatedAt: now - } - - const selection = normalizeClaudeRuntimeSelection(previousSettings) - this.store.updateSettings({ - claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account], - activeClaudeManagedAccountId: selection.host, - activeClaudeManagedAccountIdsByRuntime: selection - }) - this.runtimeAuth.clearLastWrittenCredentialsJson(accountId) - this.rateLimits.evictInactiveClaudeCache(accountId) - return this.getSnapshot() + return await this.persistCapturedClaudeAccount( + accountId, + managedAuth, + previousSettings, + captured + ) } catch (error) { - // Duplicate detection precedes every credential/settings write, so only - // its throwaway auth directory needs cleanup. - if (!duplicateIdentityFound) { - this.restoreClaudeSettings(previousSettings) - await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() - } - await this.safeRemoveManagedAuth(accountId, managedAuthPath) + await this.cleanupFailedAdd(accountId, managedAuth.managedAuthPath, previousSettings, error) throw error } } + private async doAddAccountFromConfigDir( + configDir: string, + options?: ClaudeAccountImportOptions + ): Promise { + const accountId = randomUUID() + const managedAuth = this.createManagedAuthDir(accountId, options) + const previousSettings = this.store.getSettings() + try { + const captured = await this.captureFromExistingConfigDir( + configDir, + options?.previousLegacyCredentialsSha256 + ) + return await this.persistCapturedClaudeAccount( + accountId, + managedAuth, + previousSettings, + captured + ) + } catch (error) { + await this.cleanupFailedAdd(accountId, managedAuth.managedAuthPath, previousSettings, error) + throw error + } + } + + // Why: capture credentials from a CLAUDE_CONFIG_DIR the caller already + // authenticated (e.g. a temp dir the CLI ran `claude login` into), mirroring + // runClaudeLoginAndCapture's capture step but without spawning the interactive + // login. On Linux/Windows the credentials live in a plaintext `.credentials.json`. + private async captureFromExistingConfigDir( + configDir: string, + previousLegacyCredentialsSha256?: string | null + ): Promise { + const trimmed = configDir.trim() + if (!trimmed) { + throw new Error('A Claude config directory path is required.') + } + const resolvedDir = resolve(trimmed) + // Why: macOS keeps Claude credentials in the Keychain rather than a file, so + // only require `.credentials.json` off-darwin; captureAuthFromConfigDir reads + // the scoped Keychain item on macOS. + if (process.platform !== 'darwin' && !existsSync(join(resolvedDir, '.credentials.json'))) { + throw new Error( + `No Claude credentials found in ${resolvedDir}. Run \`claude login\` into this directory first.` + ) + } + // Why: `allowFailure` covers a non-zero exit but not a spawn error, and unlike + // the GUI flow nothing has run `claude` in this process yet — a daemon started + // with a minimal PATH (launchd/systemd) would hard-fail an add the user already + // signed in for. Identity still resolves from the config dir's oauthAccount. + let status = '' + try { + status = await this.runClaudeCommand( + ['auth', 'status', '--json'], + { windowsPath: resolvedDir, linuxPath: null, wslDistro: null }, + STATUS_TIMEOUT_MS, + { allowFailure: true } + ) + } catch (error) { + console.warn('[claude-accounts] Could not read `claude auth status`:', error) + } + // Why: this post-login RPC did not observe the legacy Keychain value before + // login unless the CLI supplied its one-way pre-login credential baseline. + const currentLegacyKeychain = await readActiveClaudeKeychainCredentialsStrict() + return this.captureAuthFromConfigDir( + resolvedDir, + status, + currentLegacyKeychain, + previousLegacyCredentialsSha256 + ) + } + + private async persistCapturedClaudeAccount( + accountId: string, + managedAuth: ManagedClaudeAuthLocation, + previousSettings: ReturnType, + captured: CapturedClaudeAuth + ): Promise { + if (!captured.identity.email) { + throw new Error('Claude login completed, but Orca could not resolve the account email.') + } + // Why: duplicate rows confuse selection and rate-limit tracking; re-authentication + // is the supported way to refresh an account that is already managed. + if ( + findDuplicateClaudeAccount(previousSettings.claudeManagedAccounts, { + email: captured.identity.email, + organizationUuid: captured.identity.organizationUuid, + managedAuthRuntime: managedAuth.managedAuthRuntime, + wslDistro: managedAuth.wslDistro + }) + ) { + throw new DuplicateClaudeAccountError('This Claude account is already added.') + } + await this.writeManagedAuth(accountId, managedAuth.managedAuthPath, captured) + + const now = Date.now() + const account: ClaudeManagedAccount = { + id: accountId, + email: captured.identity.email, + managedAuthPath: managedAuth.managedAuthPath, + managedAuthRuntime: managedAuth.managedAuthRuntime, + wslDistro: managedAuth.wslDistro, + wslLinuxAuthPath: managedAuth.wslLinuxAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: captured.identity.organizationUuid, + organizationName: captured.identity.organizationName, + createdAt: now, + updatedAt: now, + lastAuthenticatedAt: now + } + + const selection = normalizeClaudeRuntimeSelection(previousSettings) + this.store.updateSettings({ + claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account], + activeClaudeManagedAccountId: selection.host, + activeClaudeManagedAccountIdsByRuntime: selection + }) + this.runtimeAuth.clearLastWrittenCredentialsJson(accountId) + this.rateLimits.evictInactiveClaudeCache(accountId) + return this.getSnapshot() + } + + private async rollbackAddAccount( + accountId: string, + managedAuthPath: string, + previousSettings: ReturnType + ): Promise { + this.restoreClaudeSettings(previousSettings) + // Why: rollback is best-effort — a failed rematerialization must not skip the + // managed-auth cleanup below, and the caller rethrows the original add error. + try { + await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() + } catch (rollbackError) { + console.warn('[claude-accounts] Rollback rematerialization failed:', rollbackError) + } + await this.safeRemoveManagedAuth(accountId, managedAuthPath) + } + + private async cleanupFailedAdd( + accountId: string, + managedAuthPath: string, + previousSettings: ReturnType, + error: unknown + ): Promise { + if (error instanceof DuplicateClaudeAccountError) { + // Why: duplicate detection precedes writes; rollback I/O could only mask + // the useful duplicate-account error. + await this.safeRemoveManagedAuth(accountId, managedAuthPath) + return + } + await this.rollbackAddAccount(accountId, managedAuthPath, previousSettings) + } + private async doReauthenticateAccount(accountId: string): Promise { const account = this.requireAccount(accountId) const managedAuthPath = this.assertManagedAuthPath(account.managedAuthPath, accountId) @@ -587,9 +712,14 @@ export class ClaudeAccountService { private async captureAuthFromConfigDir( configDir: string, statusOutput: string, - previousLegacyKeychain: string | null + previousLegacyKeychain: string | null, + previousLegacyCredentialsSha256?: string | null ): Promise { - const credentialsJson = await this.readCapturedCredentials(configDir, previousLegacyKeychain) + const credentialsJson = await this.readCapturedCredentials( + configDir, + previousLegacyKeychain, + previousLegacyCredentialsSha256 + ) if (!credentialsJson) { throw new Error('Claude login completed, but no OAuth credentials were captured.') } @@ -600,7 +730,8 @@ export class ClaudeAccountService { private async readCapturedCredentials( configDir: string, - previousLegacyKeychain: string | null + previousLegacyKeychain: string | null, + previousLegacyCredentialsSha256?: string | null ): Promise { if (process.platform === 'darwin') { const scopedCredentialsJson = await readActiveClaudeKeychainCredentialsStrict(configDir) @@ -608,7 +739,13 @@ export class ClaudeAccountService { return scopedCredentialsJson } const legacyCredentialsJson = await readActiveClaudeKeychainCredentialsStrict() - if (legacyCredentialsJson && legacyCredentialsJson !== previousLegacyKeychain) { + const legacyChanged = + previousLegacyCredentialsSha256 === undefined + ? legacyCredentialsJson !== previousLegacyKeychain + : legacyCredentialsJson !== null && + createHash('sha256').update(legacyCredentialsJson).digest('hex') !== + previousLegacyCredentialsSha256 + if (legacyCredentialsJson && legacyChanged) { return legacyCredentialsJson } } diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index c2ae74b8911..175a42e6dbd 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -804,6 +804,11 @@ describe('CodexAccountService config sync', () => { expect(spawnMock).toHaveBeenCalledTimes(1) expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(1) + // Why: the desktop add path must pass the new account's selection target, as + // reauthenticate and select already do. Called with no argument, a WSL add + // syncs the host home that did not change and never materializes the WSL + // slot that did. + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledWith({ runtime: 'host' }) }) it('does not seed source-home hook trust when adding a self-contained account', async () => { @@ -1372,6 +1377,12 @@ describe('CodexAccountService config sync', () => { wslLinuxHomePath, managedHomeRuntime: 'wsl' }) + // Why: a WSL add must sync the WSL runtime home, not the default host lane, + // or the account it just selected stays unmaterialized until the next switch. + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledWith({ + runtime: 'wsl', + wslDistro: 'Debian' + }) } finally { Object.defineProperty(process, 'platform', { configurable: true, @@ -3860,3 +3871,124 @@ describe('CodexAccountService config sync', () => { }) }) }) + +describe('CodexAccountService.addAccountFromHome', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-accounts-')) + testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-home-')) + testState.previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = testState.userDataDir + mkdirSync(join(testState.fakeHomeDir, '.codex'), { recursive: true }) + }) + + afterEach(() => { + rmSync(testState.userDataDir, { recursive: true, force: true }) + rmSync(testState.fakeHomeDir, { recursive: true, force: true }) + if (testState.previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = testState.previousUserDataPath + } + }) + + it('registers a managed Codex account by importing an authenticated CODEX_HOME', async () => { + vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) + const sourceHome = mkdtempSync(join(tmpdir(), 'orca-codex-source-')) + writeFileSync( + join(sourceHome, 'auth.json'), + createCodexAuthJson('new@example.com', 'provider-account-1', 'refresh-token'), + 'utf-8' + ) + + try { + const settings = createSettings() + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + const result = await service.addAccountFromHome(sourceHome) + + expect(result.accounts).toHaveLength(1) + expect(result.accounts[0]?.email).toBe('new@example.com') + const managedHomePath = store.getSettings().codexManagedAccounts[0].managedHomePath + expect(existsSync(join(managedHomePath, 'auth.json'))).toBe(true) + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled() + } finally { + rmSync(sourceHome, { recursive: true, force: true }) + vi.doUnmock('../codex-cli/command') + } + }) + + it('restores settings and runtime selection when post-write activation fails', async () => { + vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) + const sourceHome = mkdtempSync(join(tmpdir(), 'orca-codex-source-rollback-')) + writeFileSync( + join(sourceHome, 'auth.json'), + createCodexAuthJson('new@example.com', 'provider-account-1', 'refresh-token'), + 'utf-8' + ) + + try { + const settings = createSettings() + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + let managedHomePath: string | null = null + runtimeHome.syncForCurrentSelection.mockImplementationOnce(() => { + managedHomePath = store.getSettings().codexManagedAccounts[0]?.managedHomePath ?? null + throw new Error('activation failed') + }) + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + await expect(service.addAccountFromHome(sourceHome)).rejects.toThrow('activation failed') + + expect(store.getSettings().codexManagedAccounts).toHaveLength(0) + expect(store.getSettings().activeCodexManagedAccountId).toBeNull() + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(2) + expect(managedHomePath).not.toBeNull() + expect(existsSync(managedHomePath!)).toBe(false) + } finally { + rmSync(sourceHome, { recursive: true, force: true }) + vi.doUnmock('../codex-cli/command') + } + }) + + it('rejects when the source home has no auth.json', async () => { + vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) + const sourceHome = mkdtempSync(join(tmpdir(), 'orca-codex-source-empty-')) + + try { + const settings = createSettings() + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + await expect(service.addAccountFromHome(sourceHome)).rejects.toThrow( + /No Codex credentials found/ + ) + expect(store.getSettings().codexManagedAccounts).toHaveLength(0) + } finally { + rmSync(sourceHome, { recursive: true, force: true }) + vi.doUnmock('../codex-cli/command') + } + }) +}) diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 677d6d569db..3618f138144 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -285,6 +285,19 @@ export class CodexAccountService { return this.serializeMutation(() => this.doAddAccount(target)) } + /** + * Registers a managed Codex account from an already-authenticated `CODEX_HOME` + * instead of driving `codex login` here. Lets the `orca account add --agent codex` + * CLI run the login in the user's own terminal on a headless host and then import + * the captured `auth.json` into managed storage. + */ + async addAccountFromHome( + sourceHome: string, + target?: CodexAccountAddTarget + ): Promise { + return this.serializeMutation(() => this.doAddAccountFromHome(sourceHome, target)) + } + async reauthenticateAccount(accountId: string): Promise { return this.serializeMutation(() => this.doReauthenticateAccount(accountId)) } @@ -708,60 +721,133 @@ export class CodexAccountService { const accountId = randomUUID() const managedHome = this.createManagedHome(accountId, target) const { managedHomePath } = managedHome - try { const canonicalConfig = this.readCanonicalConfigForManagedHome(managedHomePath) this.assertOAuthAccountAddAllowed(canonicalConfig) this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath, canonicalConfig, accountId) await this.runCodexLogin(managedHomePath) - const identity = this.readIdentityFromHome(managedHomePath, accountId) - if (!identity.email) { - throw new Error('Codex login completed, but Orca could not resolve the account email.') - } - - const now = Date.now() - const account: CodexManagedAccount = { - id: accountId, - email: identity.email, - managedHomePath, - managedHomeRuntime: managedHome.managedHomeRuntime, - wslDistro: managedHome.wslDistro, - wslLinuxHomePath: managedHome.wslLinuxHomePath, - providerAccountId: identity.providerAccountId, - workspaceLabel: identity.workspaceLabel, - workspaceAccountId: identity.workspaceAccountId, - createdAt: now, - updatedAt: now, - lastAuthenticatedAt: now - } - - const settings = this.store.getSettings() - const selection = normalizeCodexRuntimeSelection(settings) - const targetSelection = getCodexSelectionTargetForAccount(account) - this.store.updateSettings({ - codexManagedAccounts: [...settings.codexManagedAccounts, account], - activeCodexManagedAccountId: - targetSelection.runtime === 'host' ? account.id : selection.host, - activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget( - selection, - account.id, - targetSelection - ) - }) - this.safeSyncCanonicalConfigToManagedHomes() - this.runtimeHome.clearLastWrittenAuthJson(account.id) - this.runtimeHome.syncForCurrentSelection() - - // Why: switching activates the new account, so cache the outgoing account's usage for the switcher. - const outgoingAccountId = getSelectedCodexAccountIdForTarget(settings, targetSelection) - this.startQuotaRefreshInBackground(outgoingAccountId, targetSelection) - return this.getSnapshot() + return await this.persistCapturedCodexAccount(accountId, managedHome) } catch (error) { this.safeRemoveManagedHome(managedHomePath, accountId) throw error } } + private async doAddAccountFromHome( + sourceHome: string, + target?: CodexAccountAddTarget + ): Promise { + const accountId = randomUUID() + const managedHome = this.createManagedHome(accountId, target) + const { managedHomePath } = managedHome + try { + const canonicalConfig = this.readCanonicalConfigForManagedHome(managedHomePath) + this.assertOAuthAccountAddAllowed(canonicalConfig) + this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath, canonicalConfig, accountId) + this.importCodexAuthFromHome(sourceHome, managedHomePath, accountId) + return await this.persistCapturedCodexAccount(accountId, managedHome) + } catch (error) { + this.safeRemoveManagedHome(managedHomePath, accountId) + throw error + } + } + + // Why: copy the auth.json from an already-authenticated CODEX_HOME (e.g. a temp + // dir the CLI ran `codex login` into) into the managed home. Mirrors the login + // step of doAddAccount without spawning an interactive browser flow. + private importCodexAuthFromHome( + sourceHome: string, + managedHomePath: string, + accountId: string + ): void { + const trimmed = sourceHome.trim() + if (!trimmed) { + throw new Error('A Codex home directory path is required.') + } + const authPath = join(resolve(trimmed), 'auth.json') + if (!existsSync(authPath)) { + throw new Error( + `No Codex credentials found in ${resolve(trimmed)}. Run \`codex login\` into this directory first.` + ) + } + const trustedHome = this.assertManagedHomePath(managedHomePath, accountId) + writeFileAtomically(join(trustedHome, 'auth.json'), readFileSync(authPath, 'utf-8'), { + mode: 0o600 + }) + } + + private async persistCapturedCodexAccount( + accountId: string, + managedHome: ManagedHomeLocation + ): Promise { + const identity = this.readIdentityFromHome(managedHome.managedHomePath, accountId) + if (!identity.email) { + throw new Error('Codex login completed, but Orca could not resolve the account email.') + } + + const now = Date.now() + const account: CodexManagedAccount = { + id: accountId, + email: identity.email, + managedHomePath: managedHome.managedHomePath, + managedHomeRuntime: managedHome.managedHomeRuntime, + wslDistro: managedHome.wslDistro, + wslLinuxHomePath: managedHome.wslLinuxHomePath, + providerAccountId: identity.providerAccountId, + workspaceLabel: identity.workspaceLabel, + workspaceAccountId: identity.workspaceAccountId, + createdAt: now, + updatedAt: now, + lastAuthenticatedAt: now + } + + const settings = this.store.getSettings() + const selection = normalizeCodexRuntimeSelection(settings) + const targetSelection = getCodexSelectionTargetForAccount(account) + this.store.updateSettings({ + codexManagedAccounts: [...settings.codexManagedAccounts, account], + activeCodexManagedAccountId: targetSelection.runtime === 'host' ? account.id : selection.host, + activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget( + selection, + account.id, + targetSelection + ) + }) + try { + this.safeSyncCanonicalConfigToManagedHomes() + this.runtimeHome.clearLastWrittenAuthJson(account.id) + // Why: pass the account's selection target so a WSL account syncs the WSL + // runtime home instead of the default host target. + this.runtimeHome.syncForCurrentSelection(targetSelection) + } catch (error) { + // Why: settings were already written; if a post-write step fails, restore the + // previous account/selection so the caller's managed-home cleanup cannot leave + // a dangling, broken managed account behind in settings. + this.store.updateSettings({ + codexManagedAccounts: settings.codexManagedAccounts, + activeCodexManagedAccountId: settings.activeCodexManagedAccountId, + activeCodexManagedAccountIdsByRuntime: settings.activeCodexManagedAccountIdsByRuntime + }) + // Why: a failed post-write step must restore both persisted selection and + // the runtime home it drives before the new managed home is removed. + try { + this.runtimeHome.syncForCurrentSelection(targetSelection) + } catch (rollbackError) { + console.warn( + '[codex-accounts] Failed to restore runtime home during rollback:', + rollbackError + ) + } + throw error + } + + // Why: switching activates the new account, so cache the outgoing account's usage for the + // switcher — in the background, since the probe must never block or fail a durable add. + const outgoingAccountId = getSelectedCodexAccountIdForTarget(settings, targetSelection) + this.startQuotaRefreshInBackground(outgoingAccountId, targetSelection) + return this.getSnapshot() + } + private async doReauthenticateAccount(accountId: string): Promise { const account = this.requireAccount(accountId) const managedHomePath = this.ensureManagedHomeForReauthentication(account) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b972e482cc9..b505b778e4a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -12261,10 +12261,35 @@ export class OrcaRuntimeService { return this.requireAccountServices().claudeAccounts.removeAccount(accountId) } + // Why: register a managed Claude account from a CLAUDE_CONFIG_DIR the caller + // already logged into. Lets the `orca account add` CLI drive `claude login` in + // the user's terminal on a headless host, then capture the credentials here — + // the desktop GUI's interactive add flow is unreachable over a remote runtime. + addClaudeAccountFromConfigDir( + configDir: string, + options?: { + runtime?: 'host' | 'wsl' + wslDistro?: string | null + previousLegacyCredentialsSha256?: string | null + } + ): Promise { + return this.requireAccountServices().claudeAccounts.addAccountFromConfigDir(configDir, options) + } + removeCodexAccount(accountId: string): Promise { return this.requireAccountServices().codexAccounts.removeAccount(accountId) } + // Why: Codex counterpart of addClaudeAccountFromConfigDir — register a managed + // Codex account from a CODEX_HOME the caller already logged into, so headless + // hosts can add accounts via `orca account add --agent codex`. + addCodexAccountFromHome( + sourceHome: string, + target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null } + ): Promise { + return this.requireAccountServices().codexAccounts.addAccountFromHome(sourceHome, target) + } + // Why: rate-limit polling fires every 5 minutes and on account switch. // Mobile clients subscribe to receive a fresh AccountsSnapshot whenever // RateLimitService pushes new usage data, mirroring the existing diff --git a/src/main/runtime/rpc/methods/accounts.test.ts b/src/main/runtime/rpc/methods/accounts.test.ts index a88e4a8b073..dc09f93fc22 100644 --- a/src/main/runtime/rpc/methods/accounts.test.ts +++ b/src/main/runtime/rpc/methods/accounts.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type { OrcaRuntimeService } from '../../orca-runtime' import { isStreamingMethod } from '../core' import { ACCOUNT_METHODS } from './accounts' @@ -12,6 +14,63 @@ function method(name: string) { } describe('account RPC methods', () => { + it.each([ + { + methodName: 'accounts.addClaudeFromConfigDir', + params: { + configDir: join(tmpdir(), 'claude-login'), + previousLegacyCredentialsSha256: 'a'.repeat(64) + }, + runtimeMethod: 'addClaudeAccountFromConfigDir', + expectedSource: join(tmpdir(), 'claude-login'), + expectedOptions: { + runtime: undefined, + wslDistro: null, + previousLegacyCredentialsSha256: 'a'.repeat(64) + } + }, + { + methodName: 'accounts.addCodexFromHome', + params: { sourceHome: join(tmpdir(), 'codex-login') }, + runtimeMethod: 'addCodexAccountFromHome', + expectedSource: join(tmpdir(), 'codex-login'), + expectedOptions: { runtime: undefined, wslDistro: null } + } + ])('allows local-socket $methodName calls', async (testCase) => { + const add = vi.fn().mockResolvedValue({ accounts: [] }) + const runtime = { [testCase.runtimeMethod]: add } as unknown as OrcaRuntimeService + const addMethod = method(testCase.methodName) + if (isStreamingMethod(addMethod)) { + throw new Error(`${testCase.methodName} must be a request method`) + } + + await addMethod.handler(testCase.params, { runtime }) + + expect(add).toHaveBeenCalledWith(testCase.expectedSource, testCase.expectedOptions) + }) + + it.each([ + ['accounts.addClaudeFromConfigDir', { configDir: join(tmpdir(), 'claude-login') }], + ['accounts.addCodexFromHome', { sourceHome: join(tmpdir(), 'codex-login') }] + ])('rejects paired-device calls to %s', async (methodName, params) => { + const runtime = { + addClaudeAccountFromConfigDir: vi.fn(), + addCodexAccountFromHome: vi.fn() + } as unknown as OrcaRuntimeService + const addMethod = method(methodName) + if (isStreamingMethod(addMethod)) { + throw new Error(`${methodName} must be a request method`) + } + + for (const clientKind of ['mobile', 'runtime'] as const) { + await expect(addMethod.handler(params, { runtime, clientKind })).rejects.toThrow( + /only available on the Orca host runtime/ + ) + } + expect(runtime.addClaudeAccountFromConfigDir).not.toHaveBeenCalled() + expect(runtime.addCodexAccountFromHome).not.toHaveBeenCalled() + }) + it('keeps explicit account-list refreshes on the forced refresh lane', async () => { const snapshot = { claude: null, codex: null } const runtime = { @@ -23,10 +82,28 @@ describe('account RPC methods', () => { throw new Error('accounts.list must be a request method') } - await expect(list.handler(undefined, { runtime })).resolves.toBe(snapshot) + // Why: clients that send no params (mobile, web) must keep the forced lane. + await expect(list.handler(list.params?.parse({}), { runtime })).resolves.toBe(snapshot) expect(runtime.refreshAccountsForMobile).toHaveBeenCalledOnce() }) + it('skips the forced provider refresh when the caller opts out', async () => { + const snapshot = { claude: null, codex: null } + const runtime = { + refreshAccountsForMobile: vi.fn().mockResolvedValue(undefined), + getAccountsSnapshot: vi.fn(() => snapshot) + } as unknown as OrcaRuntimeService + const list = method('accounts.list') + if (isStreamingMethod(list)) { + throw new Error('accounts.list must be a request method') + } + + await expect( + list.handler(list.params?.parse({ refreshUsage: false }), { runtime }) + ).resolves.toBe(snapshot) + expect(runtime.refreshAccountsForMobile).not.toHaveBeenCalled() + }) + it('forwards a client idempotency key when consuming a Codex reset credit', async () => { const idempotencyKey = '11111111-1111-4111-8111-111111111111' const expectedScope = { diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts index 89dab471a75..41d82965d00 100644 --- a/src/main/runtime/rpc/methods/accounts.ts +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -56,6 +56,30 @@ const ConsumeCodexResetCreditParams = z }) .strict() +const AddClaudeFromConfigDirParams = z.object({ + configDir: z.string().min(1, 'Missing configDir'), + runtime: z.enum(['host', 'wsl']).optional(), + wslDistro: z.string().nullish(), + previousLegacyCredentialsSha256: z + .string() + .regex(/^[a-f0-9]{64}$/, 'Invalid legacy credential digest') + .nullable() + .optional() +}) + +const AddCodexFromHomeParams = z.object({ + sourceHome: z.string().min(1, 'Missing sourceHome'), + runtime: z.enum(['host', 'wsl']).optional(), + wslDistro: z.string().nullish() +}) + +// Why: `orca account list` prints only emails and the active ids, so it opts out +// of the forced all-provider usage refresh below — that lane bypasses the poll +// throttle and Retry-After gate and costs one serial round-trip per account. +const ListAccountsParams = z.object({ + refreshUsage: z.boolean().default(true) +}) + const AccountsUnsubscribeParams = z.object({ subscriptionId: z .unknown() @@ -64,20 +88,25 @@ const AccountsUnsubscribeParams = z.object({ }) // Why: bridges the desktop ClaudeAccountService / CodexAccountService / -// RateLimitService into the mobile WebSocket RPC. Read + switch + remove -// only — interactive add/re-auth flows spawn `claude login` / `codex login` -// PTYs that need a desktop browser, so they intentionally remain -// desktop-only. See plan in spec doc for issue #1438. +// RateLimitService into the WebSocket / local-socket RPC. Read + switch + +// remove for all clients; interactive add/re-auth flows spawn `claude login` +// / `codex login` PTYs that need a desktop browser, so they intentionally +// remain desktop-only. `accounts.addClaudeFromConfigDir` is the exception: it +// captures an already-authenticated CLAUDE_CONFIG_DIR (no PTY) so the local +// `orca account add` CLI can register accounts on a headless host; it is gated +// to the local runtime connection, never a mobile device token. See #1438. export const ACCOUNT_METHODS: readonly RpcAnyMethod[] = [ defineMethod({ name: 'accounts.list', - params: null, - handler: async (_params, { runtime }) => { + params: ListAccountsParams, + handler: async (params, { runtime }) => { // Why: ensure the snapshot reflects the latest provider state before // returning. Desktop polling pauses when the window is unfocused and // inactive-account caches only fill on AccountsPane open, so without // this the mobile UI would render stale nulls / zeroes. - await runtime.refreshAccountsForMobile() + if (params.refreshUsage) { + await runtime.refreshAccountsForMobile() + } return runtime.getAccountsSnapshot() } }), @@ -115,6 +144,35 @@ export const ACCOUNT_METHODS: readonly RpcAnyMethod[] = [ params: RemoveAccountParams, handler: async (params, { runtime }) => runtime.removeCodexAccount(params.accountId) }), + defineMethod({ + name: 'accounts.addClaudeFromConfigDir', + params: AddClaudeFromConfigDirParams, + handler: async (params, { runtime, clientKind }) => { + // Why: capturing a host filesystem path is local-socket-only; paired + // mobile and remote-runtime tokens must never read host credential paths. + if (clientKind !== undefined) { + throw new Error('Adding Claude accounts is only available on the Orca host runtime.') + } + return runtime.addClaudeAccountFromConfigDir(params.configDir, { + runtime: params.runtime, + wslDistro: params.wslDistro ?? null, + previousLegacyCredentialsSha256: params.previousLegacyCredentialsSha256 + }) + } + }), + defineMethod({ + name: 'accounts.addCodexFromHome', + params: AddCodexFromHomeParams, + handler: async (params, { runtime, clientKind }) => { + if (clientKind !== undefined) { + throw new Error('Adding Codex accounts is only available on the Orca host runtime.') + } + return runtime.addCodexAccountFromHome(params.sourceHome, { + runtime: params.runtime, + wslDistro: params.wslDistro ?? null + }) + } + }), // Why: streaming counterpart so mobile usage bars refresh in place when the // desktop's 5-minute rate-limit poll completes or when the user switches // accounts on either side. Mirrors the notifications.subscribe pattern. diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts index f64a9d7bc26..f9bd781bd0a 100644 --- a/src/main/ssh/ssh-remote-orca-cli.test.ts +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -583,6 +583,69 @@ describe('runRemoteOrcaCli', () => { expect(spawn).not.toHaveBeenCalled() }) + it('rejects interactive account add but still bridges account list', async () => { + const { runtime } = createRuntime() + const spawn = vi.fn(() => createFakeChild()) + + const addResult = await runRemoteOrcaCli( + runtime, + { argv: ['account', 'add'], cwd: '/home/alice', env: {} }, + { ...LEGACY_FALLBACK_OPTIONS, spawn: spawn as never } + ) + + expect(addResult.exitCode).toBe(1) + expect(addResult.stderr).toContain('interactive agent login') + expect(spawn).not.toHaveBeenCalled() + + const child = createFakeChild() + spawn.mockReturnValueOnce(child) + const listPromise = runRemoteOrcaCli( + runtime, + { argv: ['account', 'list'], cwd: '/home/alice', env: {} }, + { + ...LEGACY_FALLBACK_OPTIONS, + entryExists: () => true, + spawn: spawn as never + } + ) + await Promise.resolve() + child.stdout.emit('data', Buffer.from('Managed Claude accounts\n')) + child.emit('close', 0) + + await expect(listPromise).resolves.toEqual({ + stdout: 'Managed Claude accounts\n', + stderr: '', + exitCode: 0 + }) + expect(spawn).toHaveBeenCalledOnce() + }) + + it('bridges account add help because it does not start an interactive login', async () => { + const { runtime } = createRuntime() + const child = createFakeChild() + const spawn = vi.fn(() => child) + + const resultPromise = runRemoteOrcaCli( + runtime, + { argv: ['account', 'add', '--help'], cwd: '/home/alice', env: {} }, + { + ...LEGACY_FALLBACK_OPTIONS, + entryExists: () => true, + spawn: spawn as never + } + ) + await Promise.resolve() + child.stdout.emit('data', Buffer.from('Usage: orca account add\n')) + child.emit('close', 0) + + await expect(resultPromise).resolves.toEqual({ + stdout: 'Usage: orca account add\n', + stderr: '', + exitCode: 0 + }) + expect(spawn).toHaveBeenCalledOnce() + }) + it('reports host-interactive command errors as JSON envelopes with --json', async () => { const { runtime } = createRuntime() diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index acd0c94cfaf..709ec8655e3 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -40,7 +40,9 @@ const HOST_INTERACTIVE_COMMANDS: Record = { 'claude-teams': 'orca claude-teams starts an interactive Claude Code session and cannot run through the SSH relay bridge. Run it in a terminal on the Orca host machine.', 'agent-teams-tmux': - 'orca agent-teams-tmux is a tmux pane shim for the Orca host machine and cannot run through the SSH relay bridge.' + 'orca agent-teams-tmux is a tmux pane shim for the Orca host machine and cannot run through the SSH relay bridge.', + 'account add': + 'orca account add runs an interactive agent login and cannot run through the buffered SSH relay bridge. Run it directly in a terminal on the Orca host machine.' } export async function runRemoteOrcaCli( @@ -52,8 +54,9 @@ export async function runRemoteOrcaCli( const json = parsed.flags.has('json') const command = parsed.commandPath.join(' ') - const interactiveMessage = HOST_INTERACTIVE_COMMANDS[parsed.commandPath[0] ?? ''] - if (interactiveMessage) { + const interactiveMessage = + HOST_INTERACTIVE_COMMANDS[command] ?? HOST_INTERACTIVE_COMMANDS[parsed.commandPath[0] ?? ''] + if (interactiveMessage && !parsed.flags.has('help')) { if (json) { return { stdout: `${JSON.stringify(buildRemoteCliError(interactiveMessage, 'unsupported_over_ssh'), null, 2)}\n`, diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index f68fc7b86d9..c2f005ddef1 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -65,6 +65,7 @@ export const TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY = 'terminal.quick-comman export const WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'worktree.create-idempotency.v1' as const export const CODEX_RESET_CREDIT_RUNTIME_CAPABILITY = 'accounts.codex-reset-credit.v1' as const +export const ACCOUNT_IMPORT_RUNTIME_CAPABILITY = 'accounts.import-host-credentials.v1' as const // Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised. export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'terminal.create-idempotency.v2' as const @@ -108,6 +109,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, + ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CODEX_RESET_CREDIT_RUNTIME_CAPABILITY ] as const