Add host-owned OpenCode and Devin account profiles

This commit is contained in:
Neil
2026-10-02 02:40:16 -07:00
parent d9fbb4eecf
commit f98f17ec67
41 changed files with 993 additions and 56 deletions
+1
View File
@@ -196,6 +196,7 @@
"react-i18next": "17.0.15",
"serve-sim": "0.1.47",
"sherpa-onnx": "1.12.37",
"smol-toml": "1.8.0",
"ssh2": "^1.17.0",
"tldts": "7.4.14",
"tweetnacl": "^1.0.3",
+5
View File
@@ -181,6 +181,9 @@ importers:
sherpa-onnx:
specifier: 1.12.37
version: 1.12.37
smol-toml:
specifier: 1.8.0
version: 1.8.0
ssh2:
specifier: ^1.17.0
version: 1.17.0
@@ -9911,6 +9914,7 @@ snapshots:
'@swc/core-win32-arm64-msvc': 1.15.46
'@swc/core-win32-ia32-msvc': 1.15.46
'@swc/core-win32-x64-msvc': 1.15.46
optional: true
'@swc/core@1.16.2':
dependencies:
@@ -9935,6 +9939,7 @@ snapshots:
'@swc/types@0.1.27':
dependencies:
'@swc/counter': 0.1.3
optional: true
'@swc/types@0.1.28':
dependencies:
+1 -1
View File
@@ -19,7 +19,7 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
},
{
name: 'account',
keys: ['account add', 'account list'],
keys: ['account add', 'account list', 'account select', 'account remove'],
load: async () => (await import('./handlers/account.js')).ACCOUNT_HANDLERS
},
{
+37
View File
@@ -0,0 +1,37 @@
import type { ManagedDataAccountsState } from '../../shared/managed-account-types'
// 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>
}
}
export function formatDataAccounts(label: string, state: ManagedDataAccountsState): string {
return `Managed ${label} accounts (${state.accounts.length}):\n${state.accounts
.map(
(account) =>
` ${account.id} ${account.label}${account.id === state.activeAccountId ? ' (active)' : ''}`
)
.join('\n')}`
}
/** Renders a provider's managed-account list as a human-readable block, marking the active account. */
export 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')}`
}
+32 -35
View File
@@ -28,47 +28,25 @@ import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version
import type { RuntimeStatus } from '../../shared/runtime-types'
import type {
ClaudeRateLimitAccountsState,
CodexRateLimitAccountsState
CodexRateLimitAccountsState,
ManagedDataAccountsState
} from '../../shared/managed-account-types'
import {
type InteractiveLoginSession,
withInteractiveLoginCleanup
} from './interactive-login-interruption'
import { getWslAccountTarget } from './account-wsl-location'
import { addDataAccount, listDataAccounts, mutateDataAccount } from './data-account-commands'
import { formatAccountsBlock, formatDataAccounts } from './account-list-format'
// Why: add returns just that provider's state; list returns the full snapshot.
type AccountsListSnapshot = {
opencode?: ManagedDataAccountsState
devin?: ManagedDataAccountsState
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
@@ -310,13 +288,17 @@ export const ACCOUNT_HANDLERS: Record<string, CommandHandler> = {
)
}
const agent = agentFlag ?? 'claude'
if (agent !== 'claude' && agent !== 'codex') {
if (agent !== 'claude' && agent !== 'codex' && agent !== 'opencode' && agent !== 'devin') {
throw new RuntimeClientError(
'invalid_argument',
`Unsupported --agent "${agent}". Use "claude" or "codex".`
`Unsupported --agent "${agent}". Use "claude", "codex", "opencode", or "devin".`
)
}
rejectAccountRemoteSelectionFlags(ctx, 'orca account add')
if (agent === 'opencode' || agent === 'devin') {
await addDataAccount(ctx, agent, runAgentLoginInTerminal)
return
}
// Why: fail on runtime version skew before burning a full OAuth round trip.
await assertAccountImportSupported(ctx)
await ctx.client.call('accounts.list', { refreshUsage: false })
@@ -324,17 +306,32 @@ export const ACCOUNT_HANDLERS: Record<string, CommandHandler> = {
},
'account list': async (ctx) => {
rejectAccountRemoteSelectionFlags(ctx, 'orca account list')
const provider = ctx.flags.get('agent')
if (provider !== undefined) {
await listDataAccounts(ctx, provider)
return
}
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)}`
printResult(result, json, (snapshot) =>
[
formatAccountsBlock('Claude', snapshot.claude),
formatAccountsBlock('Codex', snapshot.codex),
...(snapshot.opencode ? [formatDataAccounts('OpenCode', snapshot.opencode)] : []),
...(snapshot.devin ? [formatDataAccounts('Devin', snapshot.devin)] : [])
].join('\n\n')
)
},
'account select': async (ctx) => {
rejectAccountRemoteSelectionFlags(ctx, 'orca account select')
await mutateDataAccount(ctx, 'select')
},
'account remove': async (ctx) => {
rejectAccountRemoteSelectionFlags(ctx, 'orca account remove')
await mutateDataAccount(ctx, 'remove')
}
}
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { RuntimeClient } from '../runtime-client'
import { DATA_ACCOUNT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
import { addDataAccount } from './data-account-commands'
const client = new RuntimeClient(join(tmpdir(), 'orca-login-test'), 1000, null, null)
const context = {
client,
cwd: tmpdir(),
flags: new Map([['integration', 'opencode-go']]),
json: true,
rawArgs: []
}
afterEach(() => vi.restoreAllMocks())
describe('managed data account enrollment', () => {
it('refuses an old host before starting login', async () => {
vi.spyOn(client, 'call').mockResolvedValue({
id: 'test',
ok: true,
result: { capabilities: [] },
_meta: { runtimeId: 'test' }
})
const login = vi.fn()
await expect(addDataAccount(context, 'opencode', login)).rejects.toThrow('Update or restart')
expect(login).not.toHaveBeenCalled()
})
it('isolates official login and removes credentials after failed capture', async () => {
const call = vi.spyOn(client, 'call')
call
.mockResolvedValueOnce({
id: 'test',
ok: true,
result: { capabilities: [DATA_ACCOUNT_RUNTIME_CAPABILITY] },
_meta: { runtimeId: 'test' }
})
.mockRejectedValueOnce(new Error('capture failed'))
let directory = ''
const login = vi.fn(async (command: string, args: string[], env: Record<string, string>) => {
expect(command).toBe('opencode')
expect(args).toEqual(['auth', 'login', 'opencode-go', '--standalone'])
directory = dirname(env.XDG_DATA_HOME)
expect(env.XDG_STATE_HOME).toBe(join(directory, 'state'))
expect(env.OPENCODE_AUTH_CONTENT).toBe('')
expect(env.OPENCODE_DB).toBe('opencode.db')
expect(existsSync(directory)).toBe(true)
})
await expect(addDataAccount(context, 'opencode', login)).rejects.toThrow('capture failed')
expect(call).toHaveBeenLastCalledWith('accounts.addDataFromHome', {
provider: 'opencode',
sourceDataHome: join(directory, 'data'),
label: 'opencode'
})
expect(existsSync(directory)).toBe(false)
})
})
+151
View File
@@ -0,0 +1,151 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { HandlerContext } from '../dispatch'
import { printResult } from '../format'
import { RuntimeClientError } from '../runtime-client'
import { DATA_ACCOUNT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
import type { RuntimeStatus } from '../../shared/runtime-types'
import type {
ManagedDataAccountProvider,
ManagedDataAccountsState
} from '../../shared/managed-account-types'
import {
withInteractiveLoginCleanup,
type InteractiveLoginSession
} from './interactive-login-interruption'
import { getWslAccountTarget } from './account-wsl-location'
import { formatDataAccounts } from './account-list-format'
export async function assertDataAccountsSupported(ctx: HandlerContext): Promise<void> {
const status = await ctx.client.call<RuntimeStatus>('status.get')
if (!status.result.capabilities?.includes(DATA_ACCOUNT_RUNTIME_CAPABILITY)) {
throw new RuntimeClientError(
'incompatible_runtime',
'Update or restart this Orca host to manage OpenCode and Devin accounts.'
)
}
}
export async function addDataAccount(
ctx: HandlerContext,
provider: ManagedDataAccountProvider,
login: (
command: string,
args: string[],
extraEnv: Record<string, string>,
json: boolean,
session: InteractiveLoginSession
) => Promise<void>
): Promise<void> {
if (getWslAccountTarget(ctx.cwd)?.runtime === 'wsl') {
throw new RuntimeClientError(
'invalid_argument',
'Run this command inside the WSL host runtime; Windows-hosted WSL account import is not supported.'
)
}
const label = ctx.flags.get('label') ?? provider
if (typeof label !== 'string' || !label.trim() || label.trim().length > 120) {
throw new RuntimeClientError('invalid_argument', '--label must contain 1–120 characters.')
}
await assertDataAccountsSupported(ctx)
const integration = ctx.flags.get('integration')
if (
integration !== undefined &&
(provider !== 'opencode' || typeof integration !== 'string' || !integration.trim())
) {
throw new RuntimeClientError(
'invalid_argument',
'--integration requires an OpenCode integration ID or name.'
)
}
const directory = mkdtempSync(join(tmpdir(), `orca-account-add-${provider}-`))
const session: InteractiveLoginSession = {
child: null,
registering: false,
terminationPromise: null
}
const result = await withInteractiveLoginCleanup(
session,
async () => {
rmSync(directory, { recursive: true, force: true })
},
async () => {
const dataHome = join(directory, 'data')
await login(
provider,
provider === 'opencode'
? [
'auth',
'login',
...(typeof integration === 'string' ? [integration] : []),
'--standalone'
]
: ['auth', 'login', '--force-manual-token-flow'],
{
XDG_DATA_HOME: dataHome,
XDG_CONFIG_HOME: join(directory, 'config'),
XDG_CACHE_HOME: join(directory, 'cache'),
XDG_STATE_HOME: join(directory, 'state'),
...(provider === 'opencode'
? {
OPENCODE_CONFIG_DIR: join(directory, 'config', 'opencode'),
OPENCODE_AUTH_CONTENT: '',
OPENCODE_DB: 'opencode.db'
}
: {})
},
ctx.json,
session
)
session.registering = true
return ctx.client.call<ManagedDataAccountsState>('accounts.addDataFromHome', {
provider,
sourceDataHome: dataHome,
label: label.trim()
})
}
)
printResult(result, ctx.json, (state) => formatDataAccounts(provider, state))
}
export async function listDataAccounts(ctx: HandlerContext, provider: unknown): Promise<void> {
if (provider !== 'opencode' && provider !== 'devin') {
throw new RuntimeClientError('invalid_argument', 'Use --agent opencode or --agent devin.')
}
await assertDataAccountsSupported(ctx)
const result =
await ctx.client.call<Partial<Record<ManagedDataAccountProvider, ManagedDataAccountsState>>>(
'accounts.listData'
)
printResult(result, ctx.json, (snapshot) => {
const state = snapshot[provider]
if (!state) {
throw new RuntimeClientError(
'incompatible_runtime',
'Managed accounts are unavailable on this host.'
)
}
return formatDataAccounts(provider, state)
})
}
export async function mutateDataAccount(
ctx: HandlerContext,
action: 'select' | 'remove'
): Promise<void> {
const provider = ctx.flags.get('agent')
const id = ctx.flags.get('account')
if ((provider !== 'opencode' && provider !== 'devin') || typeof id !== 'string' || !id) {
throw new RuntimeClientError(
'invalid_argument',
'Use --agent opencode|devin and --account <id> (system for the default selection).'
)
}
await assertDataAccountsSupported(ctx)
const result = await ctx.client.call<ManagedDataAccountsState>(`accounts.${action}Data`, {
provider,
accountId: action === 'select' && id === 'system' ? null : id
})
printResult(result, ctx.json, (state) => formatDataAccounts(provider, state))
}
+1 -1
View File
@@ -178,7 +178,7 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
// 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)'
return '--agent <id> Account provider: claude, codex, opencode, or devin (default claude)'
}
if (flag === 'key' && command === 'computer hotkey') {
return '--key <key-combo> Modifier chord with one key, e.g. CmdOrCtrl+A'
+2 -2
View File
@@ -443,10 +443,10 @@ describe('orca root help', () => {
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'
'account add Add a managed agent 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'
'account list List managed agent accounts on this Orca host'
)
logSpy.mockRestore()
})
+4 -2
View File
@@ -18,8 +18,10 @@ export const ROOT_HELP_TEXT_PRIMARY = [
' search Search the full text of agent sessions on one Orca host',
'',
'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',
' account add Add a managed agent account on this Orca host',
' account list List managed agent accounts on this Orca host',
' account select Select an OpenCode or Devin account for new launches',
' account remove Remove an OpenCode or Devin account and its private data',
'',
'Skills:',
' skills installed List installed skill selectors',
+1 -1
View File
@@ -33,7 +33,7 @@ describe('account command specs', () => {
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).toContain('Account provider: claude, codex, opencode, or devin (default claude)')
expect(help).not.toContain('TUI agent')
})
+24 -6
View File
@@ -8,12 +8,15 @@ import { GLOBAL_FLAGS, type CommandSpec } from '../args'
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'],
summary: 'Add a managed agent account by signing in on this Orca host',
usage: 'orca account add [--agent claude|codex|opencode|devin] [--label <name>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'agent', 'label', 'integration'],
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.',
'OpenCode 2 uses `opencode auth login --standalone` in private XDG directories. Devin uses `devin auth login --force-manual-token-flow`.',
'Use --integration <id> to skip the OpenCode integration picker; --label names the saved OpenCode or Devin profile.',
'OpenCode and Devin profiles apply to new explicit host agent launches. Direct SSH relay and Windows-hosted WSL selection are not supported; run the command on a headless Orca runtime on that host.',
'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.'
],
@@ -21,12 +24,27 @@ export const ACCOUNT_COMMAND_SPECS: CommandSpec[] = [
},
{
path: ['account', 'list'],
summary: 'List managed Claude and Codex accounts on this Orca host',
usage: 'orca account list [--json]',
allowedFlags: [...GLOBAL_FLAGS],
summary: 'List managed agent accounts on this Orca host',
usage: 'orca account list [--agent opencode|devin] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'agent'],
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']
},
{
path: ['account', 'select'],
summary: 'Select an OpenCode or Devin profile for new agent launches',
usage: 'orca account select --agent opencode|devin --account <id|system> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'agent', 'account']
},
{
path: ['account', 'remove'],
summary: 'Remove a managed OpenCode or Devin profile and its private data',
usage: 'orca account remove --agent opencode|devin --account <id> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'agent', 'account'],
notes: [
'Deletes credentials and conversation data in the managed profile. Stop its running agents first. System credentials are never removed.'
]
}
]
@@ -33,6 +33,14 @@ __orca_restore_agent_teams_path
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the Orca-managed config dir before the first prompt.
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then
export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}"
export XDG_STATE_HOME="${ORCA_DATA_ACCOUNT_STATE_HOME}"
if [[ "${ORCA_DATA_ACCOUNT_PROVIDER:-}" == opencode ]]; then
export OPENCODE_AUTH_CONTENT=""
export OPENCODE_DB="opencode.db"
fi
fi
[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"
# Why: OMP does not auto-load Orca's managed status extension; wrap only
# interactive launch invocations so subcommands such as `omp config` keep
@@ -71,6 +71,14 @@ __orca_deferred_init() {
}
__orca_restore_agent_teams_path
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then
export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}"
export XDG_STATE_HOME="${ORCA_DATA_ACCOUNT_STATE_HOME}"
if [[ "${ORCA_DATA_ACCOUNT_PROVIDER:-}" == opencode ]]; then
export OPENCODE_AUTH_CONTENT=""
export OPENCODE_DB="opencode.db"
fi
fi
[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"
# Why: OMP does not auto-load Orca's managed status extension; wrap only
# interactive launch invocations so subcommands such as `omp config` keep
@@ -43,6 +43,14 @@ fi
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the Orca-managed config dir before the first prompt.
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then
export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}"
export XDG_STATE_HOME="${ORCA_DATA_ACCOUNT_STATE_HOME}"
if [[ "${ORCA_DATA_ACCOUNT_PROVIDER:-}" == opencode ]]; then
export OPENCODE_AUTH_CONTENT=""
export OPENCODE_DB="opencode.db"
fi
fi
[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"
# Why: OMP does not auto-load Orca's managed status extension; wrap only
# interactive launch invocations so subcommands such as `omp config` keep
@@ -78,6 +78,14 @@ __orca_deferred_init() {
}
__orca_restore_agent_teams_path
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then
export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}"
export XDG_STATE_HOME="${ORCA_DATA_ACCOUNT_STATE_HOME}"
if [[ "${ORCA_DATA_ACCOUNT_PROVIDER:-}" == opencode ]]; then
export OPENCODE_AUTH_CONTENT=""
export OPENCODE_DB="opencode.db"
fi
fi
[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"
# Why: OMP does not auto-load Orca's managed status extension; wrap only
# interactive launch invocations so subcommands such as `omp config` keep
@@ -44,6 +44,14 @@ __orca_deferred_init() {
if __orca_has_feature overlay; then
# Why: remote startup files can re-export user defaults after relay spawn.
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then
export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}"
export XDG_STATE_HOME="${ORCA_DATA_ACCOUNT_STATE_HOME}"
if [[ "${ORCA_DATA_ACCOUNT_PROVIDER:-}" == opencode ]]; then
export OPENCODE_AUTH_CONTENT=""
export OPENCODE_DB="opencode.db"
fi
fi
[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"
[[ -n "${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac
# Why: OMP does not auto-load Orca's managed status extension; wrap only
@@ -1,4 +1,5 @@
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell'
import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function'
import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition'
import { BASH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK } from '../shell-templates'
@@ -37,6 +38,7 @@ __orca_restore_agent_teams_path
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the Orca-managed config dir before the first prompt.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
${MANAGED_DATA_ACCOUNT_POSIX_RESTORE}
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts.
+2
View File
@@ -4,6 +4,7 @@
* that dir's fish/vendor_conf.d, and the snippet's first act is to undo it.
*/
import { getFishCodexShellLaunchPreflight } from '../shared/codex-shell-function'
import { MANAGED_DATA_ACCOUNT_FISH_RESTORE } from '../shared/managed-data-account-shell'
import type { ShellWrapperFile } from './shell-wrapper-file-writer'
/** Exactly what Orca prepended, so the snippet can remove that and nothing else. */
@@ -69,6 +70,7 @@ function __orca_fish_xdg_handoff
status is-interactive; or return 0
function __orca_define_codex --on-event fish_prompt
functions -e __orca_define_codex
${MANAGED_DATA_ACCOUNT_FISH_RESTORE}
${getFishCodexShellLaunchPreflight()}
end
end
+2
View File
@@ -34,6 +34,7 @@ import {
restoreOrStripOverlayEnv
} from './pi-agent'
import { AGENT_HOOK_RUNTIME_ENV_KEYS } from './spawn-env-keys'
import { applyManagedDataAccountEnvironment } from '../../../managed-data-accounts/launch-environment'
/**
* Mutates `baseEnv` in place with all host-local PTY env vars and returns it.
@@ -69,6 +70,7 @@ export function buildPtyHostEnv(
? undefined
: resolvedOpenCodeConfigDir
const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand)
applyManagedDataAccountEnvironment(baseEnv, { ...opts, launchCommand: launchCommandHint })
const openCodeAgent = selectOpenCodeHookAgent(
opts.launchAgent,
launchCommandHint,
@@ -0,0 +1,103 @@
import { lstatSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { z } from 'zod'
import { parse } from 'smol-toml'
import SyncDatabase from '../sqlite/sync-database'
import { writeSecureFile } from '../../shared/secure-file'
import type { ManagedDataAccountProvider } from '../../shared/managed-account-types'
const credential = z.discriminatedUnion('type', [
z.object({ type: z.literal('key'), key: z.string().min(1) }),
z.object({ type: z.literal('api'), key: z.string().min(1) }),
z.object({
type: z.literal('oauth'),
access: z.string().min(1),
refresh: z.string(),
expires: z.number().nonnegative()
}),
z.object({ type: z.literal('wellknown'), key: z.string().min(1), token: z.string().min(1) })
])
function requireRegularFile(path: string): void {
const stat = lstatSync(path)
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16 * 1024 * 1024) {
throw new Error('Credential capture requires a regular file smaller than 16 MiB.')
}
}
export async function captureDataAccountCredentials(
provider: ManagedDataAccountProvider,
sourceDataHome: string,
destinationDataHome: string
): Promise<string[]> {
if (provider === 'devin') {
const source = join(sourceDataHome, 'devin', 'credentials.toml')
requireRegularFile(source)
const content = readFileSync(source, 'utf8')
let parsed: unknown
try {
parsed = parse(content)
} catch {
throw new Error('Unsupported Devin credential format.')
}
if (!z.object({ windsurf_api_key: z.string().trim().min(1) }).safeParse(parsed).success) {
throw new Error('Devin login did not save supported credentials.')
}
if (!writeSecureFile(join(destinationDataHome, 'devin', 'credentials.toml'), content)) {
throw new Error('Could not restrict Devin credential file permissions.')
}
return ['devin']
}
const databasePath = join(sourceDataHome, 'opencode', 'opencode.db')
requireRegularFile(databasePath)
const database = new SyncDatabase(databasePath, {
readonly: true,
fileMustExist: true,
timeout: 1500
})
try {
database.pragma('query_only = ON')
const sessionTables = ['session', 'session_v2'].filter((name) =>
database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)
)
if (sessionTables.length === 0) {
throw new Error('Unsupported OpenCode credential database.')
}
// Both released session schemas must be empty before copying credentials.
for (const table of sessionTables) {
if (database.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get()) {
throw new Error(
'Use an isolated OpenCode login directory; importing conversation databases is not supported.'
)
}
}
const rows = database.prepare('SELECT integration_id, value FROM credential LIMIT 65').all()
if (rows.length === 0 || rows.length > 64) {
throw new Error('OpenCode login did not save a supported credential.')
}
const integrations = rows.map((row) => {
if (typeof row.integration_id !== 'string' || typeof row.value !== 'string') {
throw new Error('Unsupported OpenCode credential database.')
}
let value: unknown
try {
value = JSON.parse(row.value)
} catch {
throw new Error('Unsupported OpenCode credential format.')
}
if (!credential.safeParse(value).success) {
throw new Error('Unsupported OpenCode credential format.')
}
return row.integration_id
})
const destination = join(destinationDataHome, 'opencode', 'opencode.db')
if (!writeSecureFile(destination, '')) {
throw new Error('Could not restrict OpenCode credential file permissions.')
}
await database.backup(destination)
return integrations
} finally {
database.close()
}
}
@@ -0,0 +1,39 @@
import {
getCommandTokenPathBasename,
getFirstCommandToken
} from '../../shared/command-token-scanner'
import type { TuiAgent } from '../../shared/tui-agent'
import { getManagedDataAccountService } from './service'
export function applyManagedDataAccountEnvironment(
environment: Record<string, string>,
options: { launchAgent?: TuiAgent; launchCommand?: string; isWsl?: boolean }
): void {
if (options.isWsl) {
return
}
const agent =
options.launchAgent ??
getCommandTokenPathBasename(getFirstCommandToken(options.launchCommand ?? '')).replace(
/\.(?:exe|cmd|sh)$/i,
''
)
const provider =
agent === 'opencode' || agent === 'opencode2' ? 'opencode' : agent === 'devin' ? 'devin' : null
if (!provider) {
return
}
const selected = getManagedDataAccountService().launchEnvironment(provider)
if (!selected.XDG_DATA_HOME) {
return
}
Object.assign(environment, selected)
environment.ORCA_DATA_ACCOUNT_DATA_HOME = selected.XDG_DATA_HOME
environment.ORCA_DATA_ACCOUNT_STATE_HOME = selected.XDG_STATE_HOME
environment.ORCA_DATA_ACCOUNT_PROVIDER = provider
if (provider === 'opencode') {
// Database overrides and inline auth would bypass this profile's credentials.
environment.OPENCODE_AUTH_CONTENT = ''
environment.OPENCODE_DB = 'opencode.db'
}
}
@@ -0,0 +1,127 @@
import { beforeEach, afterEach, describe, expect, it } from 'vitest'
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import SyncDatabase from '../sqlite/sync-database'
import { ManagedDataAccountService } from './service'
let root: string
let source: string
let service: ManagedDataAccountService
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'orca-data-accounts-test-'))
source = join(root, 'source')
mkdirSync(join(source, 'devin'), { recursive: true })
writeFileSync(join(source, 'devin', 'credentials.toml'), 'windsurf_api_key = "test-only-key"\n')
service = new ManagedDataAccountService(join(root, 'managed'))
})
afterEach(() => rmSync(root, { recursive: true, force: true }))
function openCodeSource(sessionTable = 'session'): void {
mkdirSync(join(source, 'opencode'), { recursive: true })
const db = new SyncDatabase(join(source, 'opencode', 'opencode.db'))
db.exec(
`CREATE TABLE ${sessionTable} (id TEXT); CREATE TABLE credential (integration_id TEXT, value TEXT)`
)
db.prepare('INSERT INTO credential VALUES (?, ?)').run(
'opencode-go',
JSON.stringify({ type: 'key', key: 'test-only-key' })
)
db.close()
}
describe('managed data accounts', () => {
it('registers private Devin credentials, exposes summaries, and removes only its profile', async () => {
const state = await service.add('devin', source, 'Work')
const id = state.accounts[0].id
expect(JSON.stringify(state)).not.toContain('test-only-key')
const environment = service.launchEnvironment('devin')
expect(
readFileSync(join(environment.XDG_DATA_HOME, 'devin', 'credentials.toml'), 'utf8')
).toContain('test-only-key')
if (process.platform !== 'win32') {
expect(
statSync(join(environment.XDG_DATA_HOME, 'devin', 'credentials.toml')).mode & 0o777
).toBe(0o600)
}
await service.select('devin', null)
expect(service.launchEnvironment('devin')).toEqual({})
await service.select('devin', id)
await service.remove('devin', id)
expect(service.list('devin')).toEqual({ accounts: [], activeAccountId: null })
expect(existsSync(join(environment.XDG_DATA_HOME, 'devin', 'credentials.toml'))).toBe(false)
expect(existsSync(join(source, 'devin', 'credentials.toml'))).toBe(true)
})
it('captures OpenCode 2 SQLite credentials including WAL without leaking secrets', async () => {
openCodeSource('session_v2')
const writer = new SyncDatabase(join(source, 'opencode', 'opencode.db'))
writer.pragma('journal_mode = WAL')
writer
.prepare('INSERT INTO credential VALUES (?, ?)')
.run('google', JSON.stringify({ type: 'key', key: 'second-test-key' }))
try {
const state = await service.add('opencode', source, 'Work')
expect(state.accounts[0].integrations).toEqual(['opencode-go', 'google'])
const env = service.launchEnvironment('opencode')
const captured = new SyncDatabase(join(env.XDG_DATA_HOME, 'opencode', 'opencode.db'), {
readonly: true
})
expect(captured.prepare('SELECT COUNT(*) AS count FROM credential').get()?.count).toBe(2)
captured.close()
if (process.platform !== 'win32') {
expect(statSync(join(env.XDG_DATA_HOME, 'opencode', 'opencode.db')).mode & 0o777).toBe(
0o600
)
}
} finally {
writer.close()
}
})
it('rejects importing personal conversation databases and rolls back the directory', async () => {
openCodeSource()
const db = new SyncDatabase(join(source, 'opencode', 'opencode.db'))
db.prepare('INSERT INTO session VALUES (?)').run('personal-session')
db.close()
await expect(service.add('opencode', source, 'Work')).rejects.toThrow('conversation databases')
expect(service.list('opencode').accounts).toEqual([])
expect(readdirSync(join(root, 'managed', 'opencode'))).toEqual([])
})
it('serializes overlapping enrollment so neither account is lost', async () => {
await Promise.all([service.add('devin', source, 'One'), service.add('devin', source, 'Two')])
expect(service.list('devin').accounts.map((account) => account.label)).toEqual(['One', 'Two'])
})
it('rejects a credential symlink without touching its target', async () => {
const original = join(source, 'devin', 'credentials.toml')
const target = join(root, 'private.toml')
writeFileSync(target, readFileSync(original))
rmSync(original)
symlinkSync(target, original)
await expect(service.add('devin', source, 'Work')).rejects.toThrow('regular file')
expect(readFileSync(target, 'utf8')).toContain('test-only-key')
})
it('keeps credential parse errors out of RPC messages', async () => {
writeFileSync(
join(source, 'devin', 'credentials.toml'),
'windsurf_api_key = "secret-not-for-errors'
)
await expect(service.add('devin', source, 'Work')).rejects.toThrow(
'Unsupported Devin credential format.'
)
})
})
+174
View File
@@ -0,0 +1,174 @@
import { randomUUID } from 'node:crypto'
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs'
import { join, resolve, sep } from 'node:path'
import { z } from 'zod'
import { getAppEnvironment } from '../../shared/app-environment'
import { writeSecureFile } from '../../shared/secure-file'
import type {
ManagedDataAccountProvider,
ManagedDataAccountsState
} from '../../shared/managed-account-types'
import { captureDataAccountCredentials } from './credential-capture'
const stateSchema = z.object({
accounts: z
.array(
z.object({
id: z.uuid(),
label: z.string().min(1).max(120),
integrations: z.array(z.string()).max(64),
createdAt: z.number()
})
)
.max(64),
activeAccountId: z.uuid().nullable()
})
export class ManagedDataAccountService {
private pending: Promise<unknown> = Promise.resolve()
private readonly listeners = new Set<() => void>()
constructor(private readonly root: string) {}
list(provider: ManagedDataAccountProvider): ManagedDataAccountsState {
const path = join(this.root, provider, 'accounts.json')
if (!existsSync(path)) {
return { accounts: [], activeAccountId: null }
}
this.assertOwned(path)
return stateSchema.parse(JSON.parse(readFileSync(path, 'utf8')))
}
add(
provider: ManagedDataAccountProvider,
sourceDataHome: string,
label: string
): Promise<ManagedDataAccountsState> {
return this.mutate(async () => {
const state = this.list(provider)
if (state.accounts.length >= 64) {
throw new Error('Managed account limit reached.')
}
const id = randomUUID()
const directory = join(this.root, provider, id)
mkdirSync(directory, { recursive: true, mode: 0o700 })
this.assertOwned(directory)
try {
const integrations = await captureDataAccountCredentials(
provider,
sourceDataHome,
join(directory, 'data')
)
return this.persist(provider, {
accounts: [...state.accounts, { id, label, integrations, createdAt: Date.now() }],
activeAccountId: id
})
} catch (error) {
rmSync(directory, { recursive: true, force: true })
throw error
}
})
}
select(
provider: ManagedDataAccountProvider,
accountId: string | null
): Promise<ManagedDataAccountsState> {
return this.mutate(async () => {
const state = this.list(provider)
if (accountId !== null) {
this.requireAccount(provider, accountId)
}
return this.persist(provider, { ...state, activeAccountId: accountId })
})
}
remove(
provider: ManagedDataAccountProvider,
accountId: string
): Promise<ManagedDataAccountsState> {
return this.mutate(async () => {
const state = this.list(provider)
this.requireAccount(provider, accountId)
const result = this.persist(provider, {
accounts: state.accounts.filter((account) => account.id !== accountId),
activeAccountId: state.activeAccountId === accountId ? null : state.activeAccountId
})
const directory = join(this.root, provider, accountId)
this.assertOwned(directory)
rmSync(directory, { recursive: true, force: true })
return result
})
}
launchEnvironment(provider: ManagedDataAccountProvider): Record<string, string> {
const state = this.list(provider)
if (!state.activeAccountId) {
return {}
}
const directory = this.requireAccount(provider, state.activeAccountId)
return {
XDG_DATA_HOME: join(directory, 'data'),
XDG_STATE_HOME: join(directory, 'state'),
...(provider === 'opencode' ? { OPENCODE_DB: 'opencode.db', OPENCODE_AUTH_CONTENT: '' } : {})
}
}
onChanged(listener: () => void): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
private requireAccount(provider: ManagedDataAccountProvider, id: string): string {
if (!this.list(provider).accounts.some((account) => account.id === id)) {
throw new Error('Managed account not found.')
}
const directory = join(this.root, provider, id)
this.assertOwned(directory)
return directory
}
private persist(
provider: ManagedDataAccountProvider,
state: ManagedDataAccountsState
): ManagedDataAccountsState {
const checked = stateSchema.parse(state)
const path = join(this.root, provider, 'accounts.json')
if (existsSync(path)) {
this.assertOwned(path)
}
if (!writeSecureFile(path, JSON.stringify(checked))) {
throw new Error('Could not restrict account metadata permissions.')
}
for (const listener of this.listeners) {
listener()
}
return checked
}
private assertOwned(path: string): void {
if (
lstatSync(this.root).isSymbolicLink() ||
lstatSync(path).isSymbolicLink() ||
!realpathSync(path).startsWith(realpathSync(this.root) + sep)
) {
throw new Error('Managed account path is outside Orca account storage.')
}
}
private mutate<T>(operation: () => Promise<T>): Promise<T> {
const next = this.pending.then(operation)
this.pending = next.catch(() => {})
return next
}
}
let instance: { root: string; service: ManagedDataAccountService } | undefined
export function getManagedDataAccountService(): ManagedDataAccountService {
const root = resolve(getAppEnvironment().getPath('userData'), 'managed-data-accounts')
if (instance?.root !== root) {
instance = { root, service: new ManagedDataAccountService(root) }
}
return instance.service
}
@@ -10,8 +10,11 @@ type OpenCodeDatabaseOverride = {
path: string | null
}
function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOverride {
const raw = process.env.OPENCODE_DB?.trim()
function getOpenCodeDatabaseOverride(
dataDirectory: string,
environment: NodeJS.ProcessEnv
): OpenCodeDatabaseOverride {
const raw = environment.OPENCODE_DB?.trim()
if (!raw) {
return { isConfigured: false, path: null }
}
@@ -30,10 +33,11 @@ function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOve
export async function listOpenCodeDatabases(
/** Lets a caller report the refusal; an empty list otherwise reads as
* "OpenCode not used" rather than "we could not look". */
onRefusal?: (path: string, error: WslTranscriptFsError) => void
onRefusal?: (path: string, error: WslTranscriptFsError) => void,
environment: NodeJS.ProcessEnv = process.env
): Promise<string[]> {
const dataDirectory = resolveOpenCodeDataDirectory()
const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory)
const dataDirectory = resolveOpenCodeDataDirectory(environment)
const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory, environment)
if (databaseOverride.isConfigured) {
if (!databaseOverride.path) {
return []
@@ -136,6 +136,9 @@ describe('ElectronServeBrowserProcess start-up', () => {
vi.stubEnv(key, `leaked-${key}`)
}
vi.stubEnv('ORCA_HARNESS_UNRELATED', 'preserved')
for (const key of ['ORCA_E2E_USER_DATA_DIR', 'ORCA_USER_DATA', 'ORCA_USER_DATA_PATH']) {
vi.stubEnv(key, harnessRoot)
}
const processHandle = await startProvider()
@@ -153,6 +156,9 @@ describe('ElectronServeBrowserProcess start-up', () => {
expect(spec.env).not.toHaveProperty(key)
}
expect(spec.env?.ORCA_HARNESS_UNRELATED).toBe('preserved')
for (const key of ['ORCA_E2E_USER_DATA_DIR', 'ORCA_USER_DATA', 'ORCA_USER_DATA_PATH']) {
expect(spec.env).not.toHaveProperty(key)
}
expect(processHandle.isAvailable()).toBe(true)
})
@@ -55,6 +55,9 @@ async function reserveLoopbackPort(): Promise<number> {
function electronServeEnvironment(): NodeJS.ProcessEnv {
const environment = { ...process.env }
for (const key of [
'ORCA_E2E_USER_DATA_DIR',
'ORCA_USER_DATA',
'ORCA_USER_DATA_PATH',
'AGENT_BROWSER_ARGS',
'AGENT_BROWSER_AUTO_CONNECT',
'AGENT_BROWSER_CDP',
+2
View File
@@ -1,4 +1,5 @@
import { getPowerShellOmpShellWrapper } from './pty/omp-shell-wrapper'
import { MANAGED_DATA_ACCOUNT_POWERSHELL_RESTORE } from '../shared/managed-data-account-shell'
import { getPowerShellCodexShellLaunchPreflight } from '../shared/codex-shell-function'
export { encodePowerShellCommand } from '../shared/powershell-command-encoding'
@@ -39,6 +40,7 @@ const POWERSHELL_OSC133_BOOTSTRAP = `# Orca OSC 133 shell integration for PowerS
# Profiles have already loaded normally by the time -EncodedCommand runs.
# Restore managed ownership before the shell-integration compatibility guard.
if ($env:ORCA_OPENCODE_CONFIG_DIR) { $env:OPENCODE_CONFIG_DIR = $env:ORCA_OPENCODE_CONFIG_DIR }
${MANAGED_DATA_ACCOUNT_POWERSHELL_RESTORE}
if ($env:ORCA_MIMOCODE_HOME) { $env:MIMOCODE_HOME = $env:ORCA_MIMOCODE_HOME }
if ($env:ORCA_CODEX_HOME) { $env:CODEX_HOME = $env:ORCA_CODEX_HOME }
@@ -5,6 +5,7 @@
* startup-file chain, OSC 133 hooks, and the shell-ready marker all live here.
*/
import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition'
import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell'
import { WSL_MANAGED_CLI_PATH_RESTORE } from '../wsl-managed-cli-path-restore'
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function'
@@ -50,6 +51,7 @@ ${WSL_MANAGED_CLI_PATH_RESTORE}
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the Orca-managed config dir before the first prompt.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
${MANAGED_DATA_ACCOUNT_POSIX_RESTORE}
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts.
@@ -51,7 +51,11 @@ describe('account RPC methods', () => {
it.each([
['accounts.addClaudeFromConfigDir', { configDir: join(tmpdir(), 'claude-login') }],
['accounts.addCodexFromHome', { sourceHome: join(tmpdir(), 'codex-login') }]
['accounts.addCodexFromHome', { sourceHome: join(tmpdir(), 'codex-login') }],
[
'accounts.addDataFromHome',
{ provider: 'opencode', sourceDataHome: join(tmpdir(), 'login'), label: 'Work' }
]
])('rejects paired-device calls to %s', async (methodName, params) => {
const runtime = {
addClaudeAccountFromConfigDir: vi.fn(),
+30
View File
@@ -1,5 +1,8 @@
import { defineMethod, defineStreamingMethod } from '../core'
import {
AddDataAccountParams,
SelectDataAccountParams,
RemoveDataAccountParams,
AccountsUnsubscribeParams,
AddClaudeFromConfigDirParams,
AddCodexFromHomeParams,
@@ -25,6 +28,33 @@ let accountsSubscriptionSeq = 0
// `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 = [
defineMethod({
name: 'accounts.listData',
params: null,
handler: async (_, { runtime }) => runtime.getDataAccountsSnapshot()
}),
defineMethod({
name: 'accounts.addDataFromHome',
params: AddDataAccountParams,
handler: async (params, { runtime, clientKind }) => {
if (clientKind !== undefined) {
throw new Error('Adding accounts is only available on the Orca host runtime.')
}
return runtime.addDataAccountFromHome(params.provider, params.sourceDataHome, params.label)
}
}),
defineMethod({
name: 'accounts.selectData',
params: SelectDataAccountParams,
handler: async (params, { runtime }) =>
runtime.selectDataAccount(params.provider, params.accountId)
}),
defineMethod({
name: 'accounts.removeData',
params: RemoveDataAccountParams,
handler: async (params, { runtime }) =>
runtime.removeDataAccount(params.provider, params.accountId)
}),
defineMethod({
name: 'accounts.list',
params: ListAccountsParams,
+48 -1
View File
@@ -1,4 +1,10 @@
import type { ClaudeAccountService } from '../claude-accounts/service'
import { hasAppEnvironment } from '../../shared/app-environment'
import { getManagedDataAccountService } from '../managed-data-accounts/service'
import type {
ManagedDataAccountProvider,
ManagedDataAccountsState
} from '../../shared/managed-account-types'
import type {
CodexAccountService,
CodexResetCreditRejectedBeforeProviderReason
@@ -21,6 +27,8 @@ export type RuntimeAccountServices = {
}
export type AccountsSnapshot = {
opencode?: ManagedDataAccountsState
devin?: ManagedDataAccountsState
claude: ClaudeRateLimitAccountsState
codex: CodexRateLimitAccountsState
rateLimits: RateLimitState
@@ -61,12 +69,43 @@ export class RuntimeAccountController {
getSnapshot(): AccountsSnapshot {
const { claudeAccounts, codexAccounts, rateLimits } = this.requireServices()
return {
...this.dataAccountsSnapshot(),
claude: claudeAccounts.listAccounts(),
codex: codexAccounts.listAccounts(),
rateLimits: rateLimits.getState()
}
}
dataAccountsSnapshot(): Pick<AccountsSnapshot, 'opencode' | 'devin'> {
if (!hasAppEnvironment()) {
return {}
}
const service = getManagedDataAccountService()
return { opencode: service.list('opencode'), devin: service.list('devin') }
}
addDataFromHome(
provider: ManagedDataAccountProvider,
sourceDataHome: string,
label: string
): Promise<ManagedDataAccountsState> {
return getManagedDataAccountService().add(provider, sourceDataHome, label)
}
selectData(
provider: ManagedDataAccountProvider,
accountId: string | null
): Promise<ManagedDataAccountsState> {
return getManagedDataAccountService().select(provider, accountId)
}
removeData(
provider: ManagedDataAccountProvider,
accountId: string
): Promise<ManagedDataAccountsState> {
return getManagedDataAccountService().remove(provider, accountId)
}
async refreshForMobile(): Promise<void> {
const { rateLimits } = this.requireServices()
await Promise.allSettled([
@@ -151,13 +190,21 @@ export class RuntimeAccountController {
onChanged(listener: (snapshot: AccountsSnapshot) => void): () => void {
const services = this.requireServices()
return services.rateLimits.onStateChange((rateLimits) => {
const unsubscribeData = hasAppEnvironment()
? getManagedDataAccountService().onChanged(() => listener(this.getSnapshot()))
: () => {}
const unsubscribeUsage = services.rateLimits.onStateChange((rateLimits) => {
listener({
...this.dataAccountsSnapshot(),
claude: services.claudeAccounts.listAccounts(),
codex: services.codexAccounts.listAccounts(),
rateLimits
})
})
return () => {
unsubscribeData()
unsubscribeUsage()
}
}
private requireServices(): RuntimeAccountServices {
@@ -41,6 +41,7 @@ export type RuntimeServiceCommandSurface = {
registerMobilePushDevice: RuntimeMobileNotificationController['registerPushDevice']
unregisterMobilePushDevice: RuntimeMobileNotificationController['unregisterPushDevice']
setAccountServices: RuntimeAccountController['setServices']
getDataAccountsSnapshot: RuntimeAccountController['dataAccountsSnapshot']
setCommitMessageAgentEnvironmentResolvers: RuntimeAccountController['setCommitMessageAgentEnvironment']
getCommitMessageAgentEnvironmentResolvers: RuntimeAccountController['getCommitMessageAgentEnvironment']
getAccountsSnapshot: RuntimeAccountController['getSnapshot']
@@ -54,6 +55,9 @@ export type RuntimeServiceCommandSurface = {
addClaudeAccountFromConfigDir: RuntimeAccountController['addClaudeFromConfigDir']
removeCodexAccount: RuntimeAccountController['removeCodex']
addCodexAccountFromHome: RuntimeAccountController['addCodexFromHome']
addDataAccountFromHome: RuntimeAccountController['addDataFromHome']
selectDataAccount: RuntimeAccountController['selectData']
removeDataAccount: RuntimeAccountController['removeData']
onAccountsChanged: RuntimeAccountController['onChanged']
listMobileSpeechModels: RuntimeMobileSpeechCatalog['list']
downloadMobileSpeechModel: RuntimeMobileSpeechCatalog['download']
@@ -132,6 +136,7 @@ export function installRuntimeServiceCommandSurface(
registerMobilePushDevice: notifications.registerPushDevice.bind(notifications),
unregisterMobilePushDevice: notifications.unregisterPushDevice.bind(notifications),
setAccountServices: accounts.setServices.bind(accounts),
getDataAccountsSnapshot: accounts.dataAccountsSnapshot.bind(accounts),
setCommitMessageAgentEnvironmentResolvers:
accounts.setCommitMessageAgentEnvironment.bind(accounts),
getCommitMessageAgentEnvironmentResolvers:
@@ -147,6 +152,9 @@ export function installRuntimeServiceCommandSurface(
addClaudeAccountFromConfigDir: accounts.addClaudeFromConfigDir.bind(accounts),
removeCodexAccount: accounts.removeCodex.bind(accounts),
addCodexAccountFromHome: accounts.addCodexFromHome.bind(accounts),
addDataAccountFromHome: accounts.addDataFromHome.bind(accounts),
selectDataAccount: accounts.selectData.bind(accounts),
removeDataAccount: accounts.removeData.bind(accounts),
onAccountsChanged: accounts.onChanged.bind(accounts),
listMobileSpeechModels: speech.list.bind(speech),
downloadMobileSpeechModel: speech.download.bind(speech),
+1
View File
@@ -26,6 +26,7 @@ export type ShellStartupFeature = (typeof SHELL_STARTUP_FEATURES)[number]
/** Spawn-env keys that mean this pane carries an Orca overlay the wrapper must re-apply. */
const OVERLAY_ENV_KEYS = [
'ORCA_DATA_ACCOUNT_DATA_HOME',
'ORCA_OPENCODE_CONFIG_DIR',
'ORCA_MIMOCODE_HOME',
'ORCA_OMP_STATUS_EXTENSION',
@@ -71,6 +71,10 @@ const CONTRACT_GLOBALS = new Set([
'HISTFILE',
'MIMOCODE_HOME',
'OPENCODE_CONFIG_DIR',
'OPENCODE_AUTH_CONTENT',
'OPENCODE_DB',
'XDG_DATA_HOME',
'XDG_STATE_HOME',
'PATH',
'PROMPT_COMMAND',
'PS1', // Bash appends its non-printing Readline readiness marker.
+2
View File
@@ -1,3 +1,4 @@
import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../shared/managed-data-account-shell'
/**
* The single `.zshenv` Orca writes for every transport: local PTY, daemon/SSH,
* and relay.
@@ -122,6 +123,7 @@ function getOverlayRestoreBlocks(spec: ZshStartupHookSpec): (string | null)[] {
spec.overlayRestoreComment,
spec.restores.agentTeamsPath ? AGENT_TEAMS_PATH_RESTORE_BLOCK : null,
OPENCODE_CONFIG_DIR_RESTORE,
MANAGED_DATA_ACCOUNT_POSIX_RESTORE,
MIMOCODE_HOME_RESTORE,
spec.restores.remoteCliBinDir ? REMOTE_CLI_BIN_DIR_RESTORE : null,
getPosixOmpShellWrapper(),
+14
View File
@@ -1,3 +1,17 @@
export type ManagedDataAccountProvider = 'opencode' | 'devin'
export type ManagedDataAccountSummary = {
id: string
label: string
integrations: string[]
createdAt: number
}
export type ManagedDataAccountsState = {
accounts: ManagedDataAccountSummary[]
activeAccountId: string | null
}
export type CodexManagedAccount = {
id: string
email: string
+26
View File
@@ -0,0 +1,26 @@
export const MANAGED_DATA_ACCOUNT_POSIX_RESTORE = `if [[ -n "\${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then
export XDG_DATA_HOME="\${ORCA_DATA_ACCOUNT_DATA_HOME}"
export XDG_STATE_HOME="\${ORCA_DATA_ACCOUNT_STATE_HOME}"
if [[ "\${ORCA_DATA_ACCOUNT_PROVIDER:-}" == opencode ]]; then
export OPENCODE_AUTH_CONTENT=""
export OPENCODE_DB="opencode.db"
fi
fi`
export const MANAGED_DATA_ACCOUNT_POWERSHELL_RESTORE = `if ($env:ORCA_DATA_ACCOUNT_DATA_HOME) {
$env:XDG_DATA_HOME = $env:ORCA_DATA_ACCOUNT_DATA_HOME
$env:XDG_STATE_HOME = $env:ORCA_DATA_ACCOUNT_STATE_HOME
if ($env:ORCA_DATA_ACCOUNT_PROVIDER -eq 'opencode') {
$env:OPENCODE_AUTH_CONTENT = ''
$env:OPENCODE_DB = 'opencode.db'
}
}`
export const MANAGED_DATA_ACCOUNT_FISH_RESTORE = ` if set -q ORCA_DATA_ACCOUNT_DATA_HOME; and test -n "$ORCA_DATA_ACCOUNT_DATA_HOME"
set -gx XDG_DATA_HOME "$ORCA_DATA_ACCOUNT_DATA_HOME"
set -gx XDG_STATE_HOME "$ORCA_DATA_ACCOUNT_STATE_HOME"
if test "$ORCA_DATA_ACCOUNT_PROVIDER" = opencode
set -gx OPENCODE_AUTH_CONTENT ''
set -gx OPENCODE_DB opencode.db
end
end`
+2
View File
@@ -130,6 +130,7 @@ export const WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY =
'worktree.archive-failure-blocking.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
export const DATA_ACCOUNT_RUNTIME_CAPABILITY = 'accounts.managed-data-profiles.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
@@ -424,6 +425,7 @@ export const RUNTIME_CAPABILITIES = [
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY,
ACCOUNT_IMPORT_RUNTIME_CAPABILITY,
DATA_ACCOUNT_RUNTIME_CAPABILITY,
CODEX_RESET_CREDIT_RUNTIME_CAPABILITY,
SKILL_INSTALL_CAPABILITY,
SKILL_BUNDLE_INSTALL_CAPABILITY,
@@ -1,5 +1,19 @@
import { z } from 'zod'
export const ManagedDataAccountProviderParams = z.object({
provider: z.enum(['opencode', 'devin'])
})
export const AddDataAccountParams = ManagedDataAccountProviderParams.extend({
sourceDataHome: z.string().min(1),
label: z.string().trim().min(1).max(120)
})
export const SelectDataAccountParams = ManagedDataAccountProviderParams.extend({
accountId: z.uuid().nullable()
})
export const RemoveDataAccountParams = ManagedDataAccountProviderParams.extend({
accountId: z.uuid()
})
export const CodexResetTarget = z.discriminatedUnion('runtime', [
z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(),
// Why: reset scope must identify one exact WSL distro; null means all slots only for selection.
+8 -1
View File
@@ -28,11 +28,14 @@ import {
AccountsUnsubscribeParams,
AddClaudeFromConfigDirParams,
AddCodexFromHomeParams,
AddDataAccountParams,
ConsumeCodexResetCreditParams,
ListAccountsParams,
RemoveAccountParams,
RemoveDataAccountParams,
SelectAccountParams,
SelectCodexAccountForTargetParams
SelectCodexAccountForTargetParams,
SelectDataAccountParams
} from './accounts-params'
import { PrepareCodexForWslPaneParams } from './agent-hooks-params'
import { AgentLaunch, AgentLaunchReplay } from './agent-launch-params'
@@ -558,13 +561,17 @@ import {
export const RPC_PARAMS_BY_METHOD = {
'accounts.addClaudeFromConfigDir': AddClaudeFromConfigDirParams,
'accounts.addCodexFromHome': AddCodexFromHomeParams,
'accounts.addDataFromHome': AddDataAccountParams,
'accounts.consumeCodexResetCredit': ConsumeCodexResetCreditParams,
'accounts.list': ListAccountsParams,
'accounts.listData': null,
'accounts.removeClaude': RemoveAccountParams,
'accounts.removeCodex': RemoveAccountParams,
'accounts.removeData': RemoveDataAccountParams,
'accounts.selectClaude': SelectAccountParams,
'accounts.selectCodex': SelectAccountParams,
'accounts.selectCodexForTarget': SelectCodexAccountForTargetParams,
'accounts.selectData': SelectDataAccountParams,
'accounts.subscribe': null,
'accounts.unsubscribe': AccountsUnsubscribeParams,
'agent.launch': AgentLaunch,