From 650dd48ec9a292d6dd2ee689046d60f72317bd2a Mon Sep 17 00:00:00 2001 From: Dominik Mery Date: Thu, 30 Jul 2026 21:50:07 +0200 Subject: [PATCH] feat(cli): add `orca account add` / `account list` for headless hosts (Claude + Codex) (#9177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add `orca account add` / `account list` for headless hosts The desktop "Add account" UI is disabled when the renderer drives a remote runtime (isRemoteAccountScope === kind:'environment'), so a headless server reached from a remote desktop/web client has no way to register managed Claude accounts. Add a host-local CLI path that reuses the existing capture logic: - ClaudeAccountService.addAccountFromConfigDir(): register a managed account by capturing credentials from an already-authenticated CLAUDE_CONFIG_DIR instead of spawning the interactive browser login (extracted persist/rollback helpers shared with the existing add flow) - RPC accounts.addClaudeFromConfigDir, bridged via OrcaRuntime; rejected for mobile device tokens (host-local only) - `orca account add` runs `claude login` in the user's own terminal into a temp CLAUDE_CONFIG_DIR, then registers it via the local runtime; `orca account list` lists managed accounts Switching (select) already works from a remote client; only adding was blocked. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cli): support Codex in `orca account add` / `account list` Mirror the Claude headless-account CLI for Codex: - CodexAccountService.addAccountFromHome(): register a managed Codex account by importing auth.json from an already-authenticated CODEX_HOME, reusing a shared persist helper extracted from doAddAccount (no interactive login spawned here) - RPC accounts.addCodexFromHome + OrcaRuntime.addCodexAccountFromHome bridge, rejected for mobile device tokens (host-local only) - `orca account add --agent claude|codex` (default claude); `orca account list` now renders both Claude and Codex managed-account blocks Co-Authored-By: Claude Opus 4.8 (1M context) * test: cover headless account-add capture paths (Claude + Codex) - ClaudeAccountService.addAccountFromConfigDir: registers a managed account by capturing an authenticated CLAUDE_CONFIG_DIR; rejects and rolls back when the dir has no .credentials.json - CodexAccountService.addAccountFromHome: imports auth.json from an authenticated CODEX_HOME into a managed account; rejects when auth.json is missing Co-Authored-By: Claude Opus 4.8 (1M context) * fix: address CodeRabbit review on headless account-add flows - CLI login spawn uses a shell on Windows so `.cmd` agent shims resolve without ENOENT (args are fixed literals, no injection risk) - Claude capture skips the `.credentials.json` precheck on macOS, where creds live in the Keychain and captureAuthFromConfigDir reads them - Claude add rollback is best-effort: a failed rematerialization no longer skips managed-auth cleanup or masks the original add error - Codex persist restores the prior account/selection if a post-write sync or rate-limit refresh fails, so a failure can't leave a dangling managed account - Codex sync passes the account's selection target (correct runtime for WSL) - Add JSDoc to the new public service methods and CLI functions Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cli): harden headless account capture * fix(cli): correct account command flag surface and interrupt cleanup - `account` commands no longer accept or advertise the browser `--page` flag; `supportsBrowserPageFlag` allow-listed them by omission, so `orca account list --page x` was silently accepted and `--help` rendered a browser-only option - account specs declare GLOBAL_FLAGS, so `--help`/`--json` render in the Options block like every other command - `--agent` on `account add` documents the account provider instead of the terminal TUI-agent meaning inherited from the shared flag table - a SIGINT/SIGTERM during the interactive login now removes the temp login dir (and restores the macOS Keychain item) before exiting 130; Node terminates without unwinding `finally`, which stranded live OAuth credentials on disk * perf(cli): stop `account list` forcing a provider usage refresh `accounts.list` awaited refreshAccountsForMobile(), which runs fetchAll({ force: true }) — bypassing both the poll throttle and the per-provider Retry-After gate — then O(N) serial per-account round trips. `orca account list` renders only emails and the active ids, so all of that work was discarded. The RPC now takes `refreshUsage` (default true, so mobile and web keep the forced lane) and the CLI opts out. Older hosts declare `params: null` and ignore the field, so a newer CLI degrades to the previous behavior rather than failing. Also documents on `account list` that `--environment` does not retarget it, matching the host-local behavior of shouldIgnoreRemoteSelection. * fix(cli): survive repeated and hangup signals during account add withInterruptCleanup latched cleanup behind a boolean, so a second signal got an already-resolved promise and its process.exit fired while the first cleanup was still inside a Keychain call (3s each) — the temp dir's OAuth credentials and the swapped macOS Keychain item both survived. Memoize the cleanup promise so every signal awaits the same run, and register with `on` instead of `once` so a second Ctrl-C cannot fall through to Node's terminate-immediately default mid-cleanup. Handle SIGHUP too. This flow exists for headless/SSH hosts, where the most likely interrupt is the connection dropping, which hangs up the login's terminal and previously ran no cleanup at all. Warn when the interrupt lands after sign-in completed: the runtime finishes the add independently of this process, so exiting 130 silently would tell the user it was cancelled when the account may exist. Reject a valueless `--agent`; the parser turns it into boolean true, which silently ran a full OAuth login for Claude when the user asked for another provider. Also lock two behaviors the refactor changed but left uncovered: a WSL Codex add must sync the WSL runtime lane rather than the default host lane, and rename the account-spec help test to describe the Options block it actually asserts rather than the usage string it never reads. * fix(build): bundle the main modules the account CLI imports electron-vite cleans out/main and emits only its declared entries, and `build:desktop` runs it after `build:cli`, so the tsc-emitted copies of `claude-accounts/keychain`, `codex-cli/command` and `win32-utils` were deleted before packaging. Both `orca account add` and `orca account list` then died at require time with "Cannot find module '../../main/claude-accounts/keychain'" — reproduced against a real `--serve` host. `agent-hooks/managed-agent-hook-controls` already carried an entry for exactly this reason; these three were missing. Adds a parity test so any future CLI import of a `src/main` module fails in CI rather than at a user's shell after packaging. * test: cover the desktop add-path behavior this PR changes Both changes ride in the persist/rollback helpers the existing GUI add flow shares with the new headless path, and neither had coverage: - Claude: rollbackAddAccount now guards forceMaterializeCurrentSelection- ForRollback, so a rejecting rematerialization no longer replaces the real add error nor skips safeRemoveManagedAuth. Asserts the original error surfaces and the throwaway auth dir is gone. - Codex: the desktop add now passes the account's selection target to syncForCurrentSelection, matching reauthenticate and select. Asserts the host target alongside the existing WSL assertion. Both fail when the corresponding change is reverted. * fix(cli): close the remaining account-add interrupt and preflight gaps The round-1 interrupt fix detached the signal handlers before running the finally-path cleanup, so the very window it was meant to protect — the two serial 3s `security` calls plus rmSync on the success/error path — was still covered only by Node's terminate-immediately default. Both review lanes reproduced it independently. Await cleanup first, detach in a nested finally, and stop a cleanup failure from replacing the error that actually explains why the add failed. Do not burn the interactive login when the runtime is unreachable. The RuntimeClient is lazily constructed and the first call was the registration RPC itself, so "Requires the Orca runtime to be running" was discovered only after the user completed a full OAuth round trip. Preflight with the now-cheap `accounts.list { refreshUsage: false }`. Reject `--environment` / `--pairing-code` on `account add`. shouldIgnoreRemoteSelection pins account commands to the local runtime, so `orca account add --environment homelab` silently registered the account on the laptop instead of the headless host it names. Survive a daemon that cannot spawn `claude`. `allowFailure` is honored in onClose but not onError, and unlike the GUI flow nothing has run `claude` in the daemon before this point — so a launchd/systemd daemon with a minimal PATH hard-failed an add the user had already signed in for, even though identity resolves fine from the config dir's oauthAccount. Also align the `--agent` help description with the global flag column. * fix(cli): reject runtime selectors on `account list` too `orca account list --environment homelab` was accepted and silently listed the LOCAL machine's accounts, because shouldIgnoreRemoteSelection pins account commands to the local runtime. Documenting that in --help does not reach someone who already typed the flag, and answering with the wrong host's accounts is the specific wrong answer they would act on. `account add` already errors; this makes the new command group internally consistent. The other groups in shouldIgnoreRemoteSelection keep their existing silent-ignore behavior — changing those is not this PR's job. * test: harden account-add signal tests and cover cleanup failure - Identify the handler under test by set difference instead of `process.listeners(sig).at(-1)`. Vitest installs its own once-wrapped SIGINT teardown, so the positional lookup could grab the wrong listener; the helper also asserts exactly one new listener was added. - Mock rmSync while keeping the real implementation by default, so the temp-dir assertions elsewhere stay honest. - Cover that a cleanup failure in the `finally` does not replace the error explaining why the add failed. Fails when that guard is removed. Completes the review loop's final round; the loop died on an API error before it could commit this, and its `import()` type annotation would have failed oxlint. * fix(cli): harden interactive account add * test(cli): make account cancellation coverage portable * fix(cli): preserve merged skills runtime modules --------- Co-authored-by: Dominik Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- config/electron-builder.config.cjs | 4 + .../scripts/electron-builder-config.test.mjs | 11 +- config/tsconfig.cli.json | 1 + electron.vite.config.ts | 12 +- src/cli/args.test.ts | 5 + src/cli/args.ts | 2 + src/cli/handler-group-manifest.ts | 5 + src/cli/handlers/account.test.ts | 565 ++++++++++++++++++ src/cli/handlers/account.ts | 298 +++++++++ .../interactive-login-interruption.test.ts | 121 ++++ .../interactive-login-interruption.ts | 184 ++++++ src/cli/help.ts | 11 + src/cli/index.test.ts | 14 + src/cli/index.ts | 1 + src/cli/main-module-bundle-parity.test.ts | 58 ++ src/cli/specs/account.test.ts | 53 ++ src/cli/specs/account.ts | 32 + src/cli/specs/index.ts | 2 + src/main/claude-accounts/service.test.ts | 332 +++++++++- src/main/claude-accounts/service.ts | 253 ++++++-- src/main/codex-accounts/service.test.ts | 132 ++++ src/main/codex-accounts/service.ts | 172 ++++-- src/main/runtime/orca-runtime.ts | 25 + src/main/runtime/rpc/methods/accounts.test.ts | 79 ++- src/main/runtime/rpc/methods/accounts.ts | 72 ++- src/main/ssh/ssh-remote-orca-cli.test.ts | 63 ++ src/main/ssh/ssh-remote-orca-cli.ts | 9 +- src/shared/protocol-version.ts | 2 + 28 files changed, 2389 insertions(+), 129 deletions(-) create mode 100644 src/cli/handlers/account.test.ts create mode 100644 src/cli/handlers/account.ts create mode 100644 src/cli/handlers/interactive-login-interruption.test.ts create mode 100644 src/cli/handlers/interactive-login-interruption.ts create mode 100644 src/cli/main-module-bundle-parity.test.ts create mode 100644 src/cli/specs/account.test.ts create mode 100644 src/cli/specs/account.ts 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