From f98f17ec676dc62aaa9564756ccdcbcc7ffae4be Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:18:07 -0700 Subject: [PATCH] Add host-owned OpenCode and Devin account profiles --- package.json | 1 + pnpm-lock.yaml | 5 + src/cli/handler-group-manifest.ts | 2 +- src/cli/handlers/account-list-format.ts | 37 ++++ src/cli/handlers/account.ts | 67 ++++--- .../handlers/data-account-commands.test.ts | 61 ++++++ src/cli/handlers/data-account-commands.ts | 151 +++++++++++++++ src/cli/help.ts | 2 +- src/cli/index.test.ts | 4 +- src/cli/root-help-text-primary.ts | 6 +- src/cli/specs/account.test.ts | 2 +- src/cli/specs/account.ts | 30 ++- .../daemon-bash-rcfile.txt | 8 + .../daemon-zsh-zshenv.txt | 8 + .../local-bash-rcfile.txt | 8 + .../local-zsh-zshenv.txt | 8 + .../relay-zsh-zshenv.txt | 8 + .../daemon/daemon-bash-shell-ready-rcfile.ts | 2 + src/main/fish-xdg-data-dirs-handoff.ts | 2 + src/main/ipc/pty/host-env/assembly.ts | 2 + .../credential-capture.ts | 103 +++++++++++ .../launch-environment.ts | 39 ++++ .../managed-data-accounts/service.test.ts | 127 +++++++++++++ src/main/managed-data-accounts/service.ts | 174 ++++++++++++++++++ .../opencode-database-discovery.ts | 14 +- .../electron-serve-browser-process.test.ts | 6 + .../orcad/electron-serve-browser-process.ts | 3 + src/main/powershell-osc133-bootstrap.ts | 2 + .../local-pty-shell-ready-bash-rcfile.ts | 2 + src/main/runtime/rpc/methods/accounts.test.ts | 6 +- src/main/runtime/rpc/methods/accounts.ts | 30 +++ .../runtime/runtime-account-controller.ts | 49 ++++- .../runtime-service-command-surface.ts | 8 + src/main/shell-startup-features.ts | 1 + ...ll-wrapper-generated-file-snapshot.test.ts | 4 + src/main/zsh-startup-wrapper-builder.ts | 2 + src/shared/managed-account-types.ts | 14 ++ src/shared/managed-data-account-shell.ts | 26 +++ src/shared/protocol-version.ts | 2 + src/shared/rpc-contract/accounts-params.ts | 14 ++ .../rpc-params-catalog.generated.ts | 9 +- 41 files changed, 993 insertions(+), 56 deletions(-) create mode 100644 src/cli/handlers/account-list-format.ts create mode 100644 src/cli/handlers/data-account-commands.test.ts create mode 100644 src/cli/handlers/data-account-commands.ts create mode 100644 src/main/managed-data-accounts/credential-capture.ts create mode 100644 src/main/managed-data-accounts/launch-environment.ts create mode 100644 src/main/managed-data-accounts/service.test.ts create mode 100644 src/main/managed-data-accounts/service.ts create mode 100644 src/shared/managed-data-account-shell.ts diff --git a/package.json b/package.json index a64d7e212ad..20da9dbc59a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0f724a3ecc..45b913f63cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts index bf1f1e313d1..c27ea510826 100644 --- a/src/cli/handler-group-manifest.ts +++ b/src/cli/handler-group-manifest.ts @@ -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 }, { diff --git a/src/cli/handlers/account-list-format.ts b/src/cli/handlers/account-list-format.ts new file mode 100644 index 00000000000..2b76d7a0f1e --- /dev/null +++ b/src/cli/handlers/account-list-format.ts @@ -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 + } +} + +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')}` +} diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index b2a06c6d2e2..2fdc394b87e 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -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 - } -} - -/** 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 = { ) } 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 = { }, '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('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') } } diff --git a/src/cli/handlers/data-account-commands.test.ts b/src/cli/handlers/data-account-commands.test.ts new file mode 100644 index 00000000000..a440b9ccf88 --- /dev/null +++ b/src/cli/handlers/data-account-commands.test.ts @@ -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) => { + 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) + }) +}) diff --git a/src/cli/handlers/data-account-commands.ts b/src/cli/handlers/data-account-commands.ts new file mode 100644 index 00000000000..0642992238f --- /dev/null +++ b/src/cli/handlers/data-account-commands.ts @@ -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 { + const status = await ctx.client.call('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, + json: boolean, + session: InteractiveLoginSession + ) => Promise +): Promise { + 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('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 { + 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>>( + '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 { + 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 (system for the default selection).' + ) + } + await assertDataAccountsSupported(ctx) + const result = await ctx.client.call(`accounts.${action}Data`, { + provider, + accountId: action === 'select' && id === 'system' ? null : id + }) + printResult(result, ctx.json, (state) => formatDataAccounts(provider, state)) +} diff --git a/src/cli/help.ts b/src/cli/help.ts index 8fc3b073faf..c2153e665e4 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -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 Account provider: claude or codex (default claude)' + return '--agent Account provider: claude, codex, opencode, or devin (default claude)' } if (flag === 'key' && command === 'computer hotkey') { return '--key Modifier chord with one key, e.g. CmdOrCtrl+A' diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 2a6c014b984..259fce08cca 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -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() }) diff --git a/src/cli/root-help-text-primary.ts b/src/cli/root-help-text-primary.ts index ac2a322fd86..6d6a73537e2 100644 --- a/src/cli/root-help-text-primary.ts +++ b/src/cli/root-help-text-primary.ts @@ -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', diff --git a/src/cli/specs/account.test.ts b/src/cli/specs/account.test.ts index 7e285cf3b97..27fd5568b36 100644 --- a/src/cli/specs/account.test.ts +++ b/src/cli/specs/account.test.ts @@ -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') }) diff --git a/src/cli/specs/account.ts b/src/cli/specs/account.ts index 69dd0896e8d..0f1cc0306ec 100644 --- a/src/cli/specs/account.ts +++ b/src/cli/specs/account.ts @@ -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 ] [--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 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 [--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 [--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.' + ] } ] diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index bfbd9b420e2..2d161a01289 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -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 diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 12d37e0f724..2187aaca0f0 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -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 diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index d947f956e48..fd151e03b12 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -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 diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 93fb79a907a..7172b28d2dc 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -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 diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 4299ebb42b5..102534564b7 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -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 diff --git a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts index 76f05e9ddf5..6ae5c7f4c63 100644 --- a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts +++ b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts @@ -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. diff --git a/src/main/fish-xdg-data-dirs-handoff.ts b/src/main/fish-xdg-data-dirs-handoff.ts index bec47294044..472c3fe0ff9 100644 --- a/src/main/fish-xdg-data-dirs-handoff.ts +++ b/src/main/fish-xdg-data-dirs-handoff.ts @@ -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 diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 324dbe60dc7..1cae20bfcbd 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -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, diff --git a/src/main/managed-data-accounts/credential-capture.ts b/src/main/managed-data-accounts/credential-capture.ts new file mode 100644 index 00000000000..22de0bbe2e2 --- /dev/null +++ b/src/main/managed-data-accounts/credential-capture.ts @@ -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 { + 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() + } +} diff --git a/src/main/managed-data-accounts/launch-environment.ts b/src/main/managed-data-accounts/launch-environment.ts new file mode 100644 index 00000000000..382addedef3 --- /dev/null +++ b/src/main/managed-data-accounts/launch-environment.ts @@ -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, + 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' + } +} diff --git a/src/main/managed-data-accounts/service.test.ts b/src/main/managed-data-accounts/service.test.ts new file mode 100644 index 00000000000..76cc162ea4a --- /dev/null +++ b/src/main/managed-data-accounts/service.test.ts @@ -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.' + ) + }) +}) diff --git a/src/main/managed-data-accounts/service.ts b/src/main/managed-data-accounts/service.ts new file mode 100644 index 00000000000..0c053333f82 --- /dev/null +++ b/src/main/managed-data-accounts/service.ts @@ -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 = 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 { + 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 { + 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 { + 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 { + 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(operation: () => Promise): Promise { + 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 +} diff --git a/src/main/opencode-usage/opencode-database-discovery.ts b/src/main/opencode-usage/opencode-database-discovery.ts index fb406dc7a79..3525bfbdc84 100644 --- a/src/main/opencode-usage/opencode-database-discovery.ts +++ b/src/main/opencode-usage/opencode-database-discovery.ts @@ -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 { - const dataDirectory = resolveOpenCodeDataDirectory() - const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory) + const dataDirectory = resolveOpenCodeDataDirectory(environment) + const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory, environment) if (databaseOverride.isConfigured) { if (!databaseOverride.path) { return [] diff --git a/src/main/orcad/electron-serve-browser-process.test.ts b/src/main/orcad/electron-serve-browser-process.test.ts index 4994003eef2..94a8db3205d 100644 --- a/src/main/orcad/electron-serve-browser-process.test.ts +++ b/src/main/orcad/electron-serve-browser-process.test.ts @@ -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) }) diff --git a/src/main/orcad/electron-serve-browser-process.ts b/src/main/orcad/electron-serve-browser-process.ts index ae3508289c9..c8c4409abc9 100644 --- a/src/main/orcad/electron-serve-browser-process.ts +++ b/src/main/orcad/electron-serve-browser-process.ts @@ -55,6 +55,9 @@ async function reserveLoopbackPort(): Promise { 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', diff --git a/src/main/powershell-osc133-bootstrap.ts b/src/main/powershell-osc133-bootstrap.ts index 0dd39fa2a67..57d9c2959a0 100644 --- a/src/main/powershell-osc133-bootstrap.ts +++ b/src/main/powershell-osc133-bootstrap.ts @@ -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 } diff --git a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts index 0bbcd0712ea..d16715ed496 100644 --- a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts +++ b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts @@ -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. diff --git a/src/main/runtime/rpc/methods/accounts.test.ts b/src/main/runtime/rpc/methods/accounts.test.ts index ac8948422ac..b8473d00e01 100644 --- a/src/main/runtime/rpc/methods/accounts.test.ts +++ b/src/main/runtime/rpc/methods/accounts.test.ts @@ -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(), diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts index f7fe0af90ec..9186e378da2 100644 --- a/src/main/runtime/rpc/methods/accounts.ts +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -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, diff --git a/src/main/runtime/runtime-account-controller.ts b/src/main/runtime/runtime-account-controller.ts index 45d3ade97ff..e4f1cbfe8b5 100644 --- a/src/main/runtime/runtime-account-controller.ts +++ b/src/main/runtime/runtime-account-controller.ts @@ -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 { + if (!hasAppEnvironment()) { + return {} + } + const service = getManagedDataAccountService() + return { opencode: service.list('opencode'), devin: service.list('devin') } + } + + addDataFromHome( + provider: ManagedDataAccountProvider, + sourceDataHome: string, + label: string + ): Promise { + return getManagedDataAccountService().add(provider, sourceDataHome, label) + } + + selectData( + provider: ManagedDataAccountProvider, + accountId: string | null + ): Promise { + return getManagedDataAccountService().select(provider, accountId) + } + + removeData( + provider: ManagedDataAccountProvider, + accountId: string + ): Promise { + return getManagedDataAccountService().remove(provider, accountId) + } + async refreshForMobile(): Promise { 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 { diff --git a/src/main/runtime/runtime-service-command-surface.ts b/src/main/runtime/runtime-service-command-surface.ts index 82b3204da52..fb993e625cd 100644 --- a/src/main/runtime/runtime-service-command-surface.ts +++ b/src/main/runtime/runtime-service-command-surface.ts @@ -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), diff --git a/src/main/shell-startup-features.ts b/src/main/shell-startup-features.ts index 95518967299..1a883e9f769 100644 --- a/src/main/shell-startup-features.ts +++ b/src/main/shell-startup-features.ts @@ -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', diff --git a/src/main/shell-wrapper-generated-file-snapshot.test.ts b/src/main/shell-wrapper-generated-file-snapshot.test.ts index 36cd837e4fd..9e86ebdd15c 100644 --- a/src/main/shell-wrapper-generated-file-snapshot.test.ts +++ b/src/main/shell-wrapper-generated-file-snapshot.test.ts @@ -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. diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index 4fb4f8d01cf..d6d6ef39a30 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -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(), diff --git a/src/shared/managed-account-types.ts b/src/shared/managed-account-types.ts index 7239c62e6ad..a4f7c272ac6 100644 --- a/src/shared/managed-account-types.ts +++ b/src/shared/managed-account-types.ts @@ -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 diff --git a/src/shared/managed-data-account-shell.ts b/src/shared/managed-data-account-shell.ts new file mode 100644 index 00000000000..357248220ca --- /dev/null +++ b/src/shared/managed-data-account-shell.ts @@ -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` diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 5d3217f00f3..492436f23c1 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -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, diff --git a/src/shared/rpc-contract/accounts-params.ts b/src/shared/rpc-contract/accounts-params.ts index 0a9559e9549..7fa31ee6b80 100644 --- a/src/shared/rpc-contract/accounts-params.ts +++ b/src/shared/rpc-contract/accounts-params.ts @@ -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. diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 9b6680b08c2..ca0ac8e33dc 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -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,