feat(cli): add orca account add / account list for headless hosts (Claude + Codex) (#9177)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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 <marketing@gavaplast.sk>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
Dominik Mery
2026-07-30 12:50:07 -07:00
committed by GitHub
co-authored by Dominik Claude Opus 4.8 Brennan Benson
parent 967edeb49a
commit 650dd48ec9
28 changed files with 2389 additions and 129 deletions
+4
View File
@@ -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',
@@ -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'
])
)
})
+1
View File
@@ -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",
+11 -1
View File
@@ -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.
+5
View File
@@ -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', () => {
+2
View File
@@ -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',
+5
View File
@@ -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: [
+565
View File
@@ -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<typeof NodeFs>()
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<typeof vi.spyOn>
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<string, string> }) => {
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<string, string> }) => {
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<void>((resolvePromise) => {
releaseKeychainDelete = () => resolvePromise()
})
)
const child = Object.assign(new EventEmitter(), { kill: vi.fn() })
let configDir = ''
spawnMock.mockImplementation((_command, _args, options: { env: Record<string, string> }) => {
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<void>((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<void>((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<string, string | boolean>([
['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<string, string | boolean>([[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 })
})
})
+298
View File
@@ -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<string, string | null>
}
}
/** 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<string, string>,
json: boolean,
session: InteractiveLoginSession
): Promise<void> {
await new Promise<void>((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<void> {
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<void> {
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<ClaudeRateLimitAccountsState>('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<void> {
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<CodexRateLimitAccountsState>('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<void> {
const status = await client.call<RuntimeStatus>('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<string, CommandHandler> = {
'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<AccountsListSnapshot>('accounts.list', {
refreshUsage: false
})
printResult(
result,
json,
(snapshot) =>
`${formatAccountsBlock('Claude', snapshot.claude)}\n\n${formatAccountsBlock('Codex', snapshot.codex)}`
)
}
}
@@ -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<void>((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
})
})
@@ -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<void>
type TerminationDependencies = {
platform?: NodeJS.Platform
killWindowsTree?: WindowsTreeKiller
posixGraceMs?: number
closeFallbackMs?: number
}
export type InteractiveLoginSession = {
child: ChildProcess | null
registering: boolean
terminationPromise: Promise<void> | null
}
const INTERRUPT_EXIT_CODES: Record<string, number> = { SIGHUP: 129, SIGINT: 130, SIGTERM: 143 }
const INTERRUPT_SIGNALS = Object.keys(INTERRUPT_EXIT_CODES) as NodeJS.Signals[]
function waitForClose(closePromise: Promise<void>, timeoutMs: number): Promise<boolean> {
let timer: NodeJS.Timeout | undefined
return Promise.race([
closePromise.then(() => true),
new Promise<false>((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<void> {
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<void> {
let closed = false
let resolveClose: (() => void) | undefined
const onClose = (): void => {
closed = true
resolveClose?.()
}
const closePromise = new Promise<void>((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<T>(
session: InteractiveLoginSession,
cleanup: () => Promise<void>,
add: () => Promise<T>
): Promise<T> {
let cleanupPromise: Promise<void> | null = null
let cleanupFailureReported = false
const cleanupOnce = (): Promise<void> =>
(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
}
+11
View File
@@ -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 <name> --pairing-code <code> [--json]
orca environment list [--json]
orca environment show --environment <selector> [--json]
@@ -486,6 +492,11 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
if (command === 'orchestration task-create' && flag === 'display-name') {
return '--display-name <text> 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 <id> Account provider: claude or codex (default claude)'
}
if (flag === 'key' && command === 'computer hotkey') {
return '--key <key-combo> Modifier chord with one key, e.g. CmdOrCtrl+A'
}
+14
View File
@@ -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(() => {})
+1
View File
@@ -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' ||
+58
View File
@@ -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 = /(?<!\btype\s)from '\.\.\/\.\.\/main\/([^']+)'/g
function findMainImports(): { file: string; module: string }[] {
return listCliSourceFiles(CLI_ROOT).flatMap((file) => {
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<string> {
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)
})
})
+53
View File
@@ -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'))
})
})
+32
View File
@@ -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']
}
]
+2
View File
@@ -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,
+317 -15
View File
@@ -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<typeof settings>) => {
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<void> }).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<typeof settings>) => {
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<string> }).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<string> }).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<string> }).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<string> }).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<string> }).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()
})
})
+195 -58
View File
@@ -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<ClaudeRateLimitAccountsState> {
return this.serializeMutation(() => this.doAddAccountFromConfigDir(configDir, options))
}
async reauthenticateAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
return this.serializeMutation(() => this.doReauthenticateAccount(accountId))
}
@@ -140,67 +159,173 @@ export class ClaudeAccountService {
): Promise<ClaudeRateLimitAccountsState> {
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<ClaudeRateLimitAccountsState> {
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<CapturedClaudeAuth> {
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<Store['getSettings']>,
captured: CapturedClaudeAuth
): Promise<ClaudeRateLimitAccountsState> {
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<Store['getSettings']>
): Promise<void> {
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<Store['getSettings']>,
error: unknown
): Promise<void> {
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<ClaudeRateLimitAccountsState> {
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<CapturedClaudeAuth> {
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<string | null> {
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
}
}
+132
View File
@@ -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')
}
})
})
+129 -43
View File
@@ -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<CodexRateLimitAccountsState> {
return this.serializeMutation(() => this.doAddAccountFromHome(sourceHome, target))
}
async reauthenticateAccount(accountId: string): Promise<CodexRateLimitAccountsState> {
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<CodexRateLimitAccountsState> {
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<CodexRateLimitAccountsState> {
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<CodexRateLimitAccountsState> {
const account = this.requireAccount(accountId)
const managedHomePath = this.ensureManagedHomeForReauthentication(account)
+25
View File
@@ -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<ClaudeRateLimitAccountsState> {
return this.requireAccountServices().claudeAccounts.addAccountFromConfigDir(configDir, options)
}
removeCodexAccount(accountId: string): Promise<CodexRateLimitAccountsState> {
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<CodexRateLimitAccountsState> {
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
+78 -1
View File
@@ -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 = {
+65 -7
View File
@@ -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.
+63
View File
@@ -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()
+6 -3
View File
@@ -40,7 +40,9 @@ const HOST_INTERACTIVE_COMMANDS: Record<string, string> = {
'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`,
+2
View File
@@ -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