mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
Add host-owned OpenCode and Devin account profiles
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+5
@@ -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:
|
||||
|
||||
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user