Skip to content
Open
2 changes: 2 additions & 0 deletions config/scripts/vitest-node-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export const NODE_RUNTIME_INCLUDE = [
'src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts',
'src/renderer/src/store/github/cache-persistence.test.ts',
'src/main/native-chat/agent-session-journal/**/*.test.ts',
// Account receipt replay exercises the host's durable Node SQLite ledger.
'src/main/runtime/rpc/methods/agent-launch-account-replay.test.ts',
'src/main/runtime/rpc/ws-transport.test.ts',
'src/shared/remote-runtime-client.test.ts',
'src/shared/remote-runtime-connect-bound.test.ts',
Expand Down
32 changes: 32 additions & 0 deletions docs/reference/codex-launch-account-pinning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Explicit Codex launch accounts

A coordinator can launch a new Codex worker on an existing managed account without changing the global account selection or restarting other sessions:

```sh
orca orchestration worker-start --task task_id --agent codex --account account_id --worktree current
orca worktree create --repo id:repo_id --name worker-b --agent codex --account account_id
```

Selectors accept an exact managed account id, a case-insensitive email that is unique among native host accounts, or `system` / `"System default"`. Exact ids take precedence. Unknown, ambiguous, untrusted, and unsupported account homes are refused. WSL accounts do not participate in native email matching.

Both commands require `codex.launch-account.v1` in the connected runtime's advertised capabilities. Missing or older capabilities fail before a launch mutation is sent. Connect directly to the execution runtime to use its accounts; `worker-start --on` federation, SSH-forwarded workspaces, paired-runtime repository forwarding, and WSL execution are refused. Native git worktrees and folder workspaces are supported on Windows, macOS, and Linux. Other providers, terminal reuse, session restoration, renderer-driven PTY creation, and custom Codex launch commands cannot guarantee this pin and are refused.

An explicit account uses a native terminal. If the user's default was a structured chat session, the mode receipt records `pinned_codex_account` as the downgrade reason. Omitting `--account` retains the existing launch mode, active-account routing, and account-home callback signature.

The account service resolves selectors and verifies home ownership. It prepares the requested managed home using the existing resource, config, hook, and session-history infrastructure, without invoking account switching or shared-auth transition bookkeeping. Explicit system selection uses the canonical system Codex home even when Orca inherited another pane's `CODEX_HOME`. The PTY receives that home only in its new launch environment. Existing credential readiness checks must succeed for that same home; they cannot fall back to another account. The returned home is checked against the existing account attribution resolver before spawning.

Pinned panes retain their effective account in the durable pane registry and are excluded from global-selection stale-account notices. Existing unpinned panes retain their normal account-switch behavior. No new credential store is introduced, and launch receipts contain no credentials or home paths.

`worktree.create` and `agent.launch` return an optional `account` receipt. Worker receipts contain the same value at `launch.account`, persisted in the Dispatch start options:

```json
{
"provider": "codex",
"requested": "b@example.com",
"effective": { "id": "account-b", "email": "b@example.com" }
}
```

System receipts use `requested: "system"` and null effective id/email. Email requests are normalized; id requests retain the exact id. `agent.launch` accepts `account` only at the launch level and refuses a nested `target.create.startupAccount`. It includes the selector in its operation fingerprint and validates the account receipt during authoritative replay. Retrying the same operation returns its recorded effective account without resolving or launching again; changing its account conflicts. Explicit pins refuse `worktree.create`'s legacy `clientMutationId` cache, which does not compare account intent; use `agent.launch` with `operationId` for replay.

The capability is additive and has no new stream opcode. Hosts and clients update independently, so callers must not forward the selector to an unadvertised host. This implementation refuses forwarding entirely. Automatic quota failover is separate work tracked in [#20512](https://github.com/stablyai/orca/issues/20512); the concurrency use case is described in [#9496](https://github.com/stablyai/orca/issues/9496).
123 changes: 123 additions & 0 deletions src/cli/handlers/codex-launch-account-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CommandHandler, HandlerContext } from '../dispatch'
import { CODEX_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY } from '../../shared/agent-launch-account'
import { getCodexLaunchAccountFlag } from './codex-launch-account-flag'
import { ORCHESTRATION_HANDLERS } from './orchestration'
import { WORKTREE_HANDLERS } from './worktree'

vi.mock('../format', () => ({ printResult: vi.fn(), formatWorktreeShow: vi.fn() }))

const call = vi.fn()
const receipt = {
provider: 'codex',
requested: 'account-b',
effective: { id: 'account-b', email: 'b@example.com' }
}

function context(entries: Record<string, string | boolean>): HandlerContext {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these handlers use only client.call; the mock rejects any unexpected RPC in the assertions.
return {
flags: new Map(Object.entries(entries)),
client: { call },
cwd: '/fixture',
json: true
} as unknown as HandlerContext
}

beforeEach(() => call.mockReset())

describe('explicit Codex launch CLI capability boundary', () => {
const commands: {
handler: CommandHandler
base: Record<string, string | boolean>
method: string
field: string
}[] = [
{
handler: ORCHESTRATION_HANDLERS['orchestration worker-start'],
base: { task: 'task_1', from: 'term_coord' },
method: 'orchestration.workerStart',
field: 'account'
},
{
handler: WORKTREE_HANDLERS['worktree create'],
base: { name: 'pinned', repo: 'id:repo_1', 'no-parent': true },
method: 'worktree.create',
field: 'startupAccount'
}
]

it.each(commands)(
'gates and forwards the selector through $method',
async ({ handler, base, method, field }) => {
call
.mockResolvedValueOnce({
result: { capabilities: [CODEX_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY] }
})
.mockResolvedValue({
result: {
state: 'ready',
worktree: { id: 'wt_1' },
warnings: [],
launch: { account: receipt },
account: receipt
}
})
await handler(context({ ...base, agent: 'codex', account: 'account-b' }))
expect(call.mock.calls[0]).toEqual(['status.get'])
const mutation = call.mock.calls.find(([name]) => name === method)
expect(mutation?.[1]).toHaveProperty(field, 'account-b')
}
)

it.each(commands)(
'keeps the omitted-flag RPC shape and skips status for $method',
async ({ handler, base, method, field }) => {
call.mockResolvedValue({ result: { state: 'ready', worktree: { id: 'wt_1' }, warnings: [] } })
await handler(context({ ...base, agent: 'codex' }))
expect(call).not.toHaveBeenCalledWith('status.get')
expect(call.mock.calls.find(([name]) => name === method)?.[1]).not.toHaveProperty(field)
}
)

it.each([undefined, [], ['agent.launch.v2'], null])(
'refuses missing/old host capabilities %j before mutation',
async (capabilities) => {
call.mockResolvedValue({ result: { capabilities } })
const ctx = context({ account: 'account-a', agent: 'codex' })
await expect(getCodexLaunchAccountFlag(ctx.flags, ctx.client)).rejects.toMatchObject({
code: 'incompatible_runtime'
})
expect(call).toHaveBeenCalledExactlyOnceWith('status.get')
}
)

const unsupportedRequests: Record<string, string | boolean>[] = [
{ agent: 'claude' },
{ agent: 'cursor' },
{ agent: 'codex', terminal: 'term_existing' },
{ agent: 'codex', on: 'server' },
{}
]
it.each(unsupportedRequests)(
'refuses unsupported provider or placement %j without contacting a host',
async (flags) => {
const ctx = context({ account: 'account-a', ...flags })
await expect(getCodexLaunchAccountFlag(ctx.flags, ctx.client)).rejects.toMatchObject({
code: 'invalid_argument'
})
expect(call).not.toHaveBeenCalled()
}
)

it.each([true, false, '', ' ', 'id\nsecret'])(
'refuses malformed account flag %j without dropping the pin',
async (account) => {
const ctx = context({ account, agent: 'codex' })
await expect(getCodexLaunchAccountFlag(ctx.flags, ctx.client)).rejects.toMatchObject({
code: 'invalid_argument'
})
expect(call).not.toHaveBeenCalled()
}
)
})
49 changes: 49 additions & 0 deletions src/cli/handlers/codex-launch-account-flag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
assertCodexAccountLaunchRequest,
CODEX_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY
} from '../../shared/agent-launch-account'
import { LaunchAccountParam } from '../../shared/rpc-contract/launch-account-param'
import type { RuntimeStatus } from '../../shared/runtime-types'
import { getOptionalStringFlag, getRequiredStringFlag } from '../flags'
import { RuntimeClientError, type RuntimeClient } from '../runtime-client'

export async function getCodexLaunchAccountFlag(
flags: Map<string, string | boolean>,
client: RuntimeClient
): Promise<string | undefined> {
if (!flags.has('account')) {
return undefined
}
const account = getRequiredStringFlag(flags, 'account')
const parsed = LaunchAccountParam.safeParse(account)
if (!parsed.success) {
throw new RuntimeClientError(
'invalid_argument',
'--account requires an account id, unique email, or system.'
)
}
try {
assertCodexAccountLaunchRequest({
account: parsed.data,
agent: getOptionalStringFlag(flags, 'agent'),
terminal: getOptionalStringFlag(flags, 'terminal'),
on: getOptionalStringFlag(flags, 'on')
})
} catch (error) {
throw new RuntimeClientError(
'invalid_argument',
error instanceof Error ? error.message : String(error)
)
}
const status = await client.call<RuntimeStatus>('status.get')
if (
!Array.isArray(status.result?.capabilities) ||
!status.result.capabilities.includes(CODEX_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY)
) {
throw new RuntimeClientError(
'incompatible_runtime',
'The connected Orca runtime does not support explicit Codex accounts. No launch was requested.'
)
}
return parsed.data
}
3 changes: 3 additions & 0 deletions src/cli/handlers/orchestration/worker-launch-handler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CommandHandler } from '../../dispatch'
import { printResult } from '../../format'
import { getOptionalStringFlag } from '../../flags'
import { getCodexLaunchAccountFlag } from '../codex-launch-account-flag'
import { RuntimeClientError } from '../../runtime-client'
import type { RuntimeStatus } from '../../../shared/runtime-types'
import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
Expand All @@ -13,6 +14,7 @@ import { renderResolvedOrchestrationCommand } from '../../orchestration-mutation

export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record<string, CommandHandler> = {
'orchestration worker-start': async ({ flags, client, cwd, json }) => {
const account = await getCodexLaunchAccountFlag(flags, client)
const model = getOptionalStringFlag(flags, 'model')
const effort = getOptionalStringFlag(flags, 'effort')
if (model || effort) {
Expand Down Expand Up @@ -60,6 +62,7 @@ export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record<string, CommandHandler>
comment: getOptionalStringFlag(flags, 'comment'),
setup: getOptionalStringFlag(flags, 'setup'),
agent: getOptionalStringFlag(flags, 'agent'),
...(account !== undefined ? { account } : {}),
model,
effort,
terminal: getOptionalStringFlag(flags, 'terminal'),
Expand Down
3 changes: 3 additions & 0 deletions src/cli/handlers/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type WithAnnotatedHostScope
} from '../omitted-host-scope-selectors'
import { RuntimeClientError } from '../runtime-client'
import { getCodexLaunchAccountFlag } from './codex-launch-account-flag'
import {
getOptionalPositiveIntegerFlag,
getOptionalStringFlag,
Expand Down Expand Up @@ -168,6 +169,7 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
printResult(result, json, formatWorktreeShow)
},
'worktree create': async ({ flags, client, cwd, json }) => {
const account = await getCodexLaunchAccountFlag(flags, client)
assertWorktreeParentFlagsCompatible(flags)
assertWorkspaceTargetFlagsCompatible(flags)
const reviewLinks = getReviewTargetLinkFlags(flags)
Expand Down Expand Up @@ -232,6 +234,7 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
...(startupAgent
? {
startupAgent,
...(account !== undefined ? { startupAccount: account } : {}),
startupPrompt: getPresentStringFlag(flags, 'prompt', { allowEmpty: true }) ?? '',
launchSource: 'cli'
}
Expand Down
4 changes: 3 additions & 1 deletion src/cli/specs/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
path: ['worktree', 'create'],
summary: 'Create a new Orca-managed worktree',
usage:
'orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--pr <number>] [--linear-issue <identifier-or-url>] [--gitlab-issue <number-or-url>] [--gitlab-mr <number-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
'orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--account <id|email|system>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--pr <number>] [--linear-issue <identifier-or-url>] [--gitlab-issue <number-or-url>] [--gitlab-mr <number-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'repo',
Expand All @@ -75,6 +75,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
'project-host-setup',
'name',
'agent',
'account',
'prompt',
'base-branch',
'issue',
Expand All @@ -100,6 +101,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
'--no-parent only affects Orca lineage; omit --base-branch to use the repo default base, or pass the default base ref explicitly for independent top-level work.',
'By default this creates the worktree and its first terminal without switching the active Orca view.',
'Pass --agent to launch an agent in the first terminal; --prompt sends initial work to that agent.',
'--account requires --agent codex and a runtime advertising codex.launch-account.v1. It pins an existing native host account by exact id or unique case-insensitive email; system uses the canonical system home. Global selection and existing sessions stay unchanged. SSH, WSL, forwarded hosts, and custom Codex launch commands are refused.',
'With --agent --json, read the new agent handle from result.agentTerminalHandle; older runtimes return only result.startupTerminal.handle, and may return neither for folder-based repos.',
'Repo-defined setup hooks follow the repository setup policy; pass --setup run to force them.',
'Pass --activate when the CLI caller intentionally wants to reveal the new worktree in the app.',
Expand Down
4 changes: 3 additions & 1 deletion src/cli/specs/orchestration-worker-specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'worker-start'],
summary: 'Start one supervised worker on the Run home or a connected Orca server',
usage:
'orca orchestration worker-start (--task <task_id> | --spec <text>) [--on <saved-environment>] [--worktree <current|selector|new-child|new-top-level>] (--agent <agent> | --terminal <handle>) [--task-title <text>] [--deps <json_array>] [--parent <task_id>] [--model <id>] [--effort <level>] [--name <name>] [--repo <selector>] [--base-branch <ref>] [--display-name <text>] [--comment <text>] [--setup <run|skip|inherit>] [--retry-of <dispatch_id>] [--timeout-ms <n>] [--run <run_id>] [--from <handle>] [--retry-request <id>] [--json]',
'orca orchestration worker-start (--task <task_id> | --spec <text>) [--on <saved-environment>] [--worktree <current|selector|new-child|new-top-level>] (--agent <agent> | --terminal <handle>) [--account <id|email|system>] [--task-title <text>] [--deps <json_array>] [--parent <task_id>] [--model <id>] [--effort <level>] [--name <name>] [--repo <selector>] [--base-branch <ref>] [--display-name <text>] [--comment <text>] [--setup <run|skip|inherit>] [--retry-of <dispatch_id>] [--timeout-ms <n>] [--run <run_id>] [--from <handle>] [--retry-request <id>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'task',
Expand All @@ -22,6 +22,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
'comment',
'setup',
'agent',
'account',
'model',
'effort',
'terminal',
Expand All @@ -36,6 +37,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.',
'When reusing --terminal, pass --worktree for that terminal; current means the coordinator worktree.',
'--agent takes an Orca agent id enabled on the worker server, such as claude, codex, cursor, antigravity, muse, zcode, opencode, or opencode2.',
'--account requires --agent codex and codex.launch-account.v1 on the connected runtime. Exact managed id or unique case-insensitive native host email pins only the new launch; system uses the canonical system home. It uses terminal mode with an explicit receipt, leaves global selection and existing sessions unchanged, and rejects --terminal, --on, SSH, WSL, and custom Codex launch commands.',
'--model supports Claude, Codex, Cursor, Antigravity, and Muse opaque provider model ids; --effort requires --model. OMP accepts --model only; --effort is unsupported. Neither can combine with --terminal. OpenCode model selection requires an existing worktree, a verified execution-host CLI, and an available model; effort is unsupported. Other agents, including zcode, launch with the model from their own config.',
'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.',
'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.',
Expand Down
Loading