From f98f17ec676dc62aaa9564756ccdcbcc7ffae4be Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:18:07 -0700 Subject: [PATCH 01/15] 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, From 297d6f3a2d08b152276b6930cfe007ce99d3e52e Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:19:38 -0700 Subject: [PATCH 02/15] Manage OpenCode and Devin profiles in account Settings --- docs/reference/managed-data-accounts.md | 23 ++ .../src/components/settings/AccountsPane.tsx | 8 + .../settings/ManagedDataAccountsSection.tsx | 232 ++++++++++++++++++ src/renderer/src/i18n/locales/en.json | 16 ++ src/renderer/src/i18n/locales/es.json | 16 ++ src/renderer/src/i18n/locales/fr.json | 16 ++ src/renderer/src/i18n/locales/ja.json | 16 ++ src/renderer/src/i18n/locales/ko.json | 16 ++ src/renderer/src/i18n/locales/zh.json | 16 ++ 9 files changed, 359 insertions(+) create mode 100644 docs/reference/managed-data-accounts.md create mode 100644 src/renderer/src/components/settings/ManagedDataAccountsSection.tsx diff --git a/docs/reference/managed-data-accounts.md b/docs/reference/managed-data-accounts.md new file mode 100644 index 00000000000..a8db7c6762f --- /dev/null +++ b/docs/reference/managed-data-accounts.md @@ -0,0 +1,23 @@ +# Managed OpenCode and Devin accounts + +Run enrollment in a terminal on the machine running Orca: + +```sh +orca account add --agent opencode --label Work +orca account add --agent opencode --integration opencode-go --label Work +orca account add --agent devin --label Work +orca account list --agent opencode --json +orca account select --agent opencode --account +orca account select --agent opencode --account system +orca account remove --agent opencode --account +``` + +OpenCode enrollment requires OpenCode 2 and runs its official `auth login --standalone` command. Devin runs `auth login --force-manual-token-flow`; obtain the enrollment token through Devin's supported login flow. These commands neither reuse a guessed token nor sign out the system account. Settings → AI Provider Accounts provides the enrollment command, refresh, selection, and removal for the selected Orca host. + +Each profile belongs to the execution host. OpenCode's SQLite credentials and Devin's credential TOML stay in private Orca user-data directories. Enrollment isolates XDG data/config/cache/state, copies only authenticated credentials, and then deletes the temporary directory. OpenCode capture rejects databases containing conversations and includes SQLite WAL contents. RPC summaries contain labels, IDs, and integration names, never tokens or credential paths. Only the authenticated local runtime socket can import a credential directory; paired clients cannot ask the host to read arbitrary paths. + +Selection affects newly launched explicit OpenCode/Devin commands and agent launches. It redirects XDG data and state; OpenCode inline-auth/database overrides cannot bypass the profile. Shell wrappers restore this selection after user startup files. Existing provider configuration and environment-based integrations remain available. Running terminals retain their current profile. Stop agents before removing a profile: removal also deletes conversations created in that private profile, without changing the system login. + +For SSH, enroll by running the command on a headless Orca runtime on the remote machine. The remote runtime owns its profiles and selection; a desktop client's credential paths never cross SSH. Direct SSH relay launches and Windows-hosted WSL panes do not consume the desktop host's profiles. Run a headless runtime inside that execution environment instead. Folder workspaces use the same host account store as git worktrees. Older Orca hosts reject new operations before login through capability negotiation. + +Validation currently covers OpenCode 2.0.16 on macOS, real isolated login and selected terminal authentication, Devin 3000.10.31 saved-login recognition, and the Node headless runtime. Fresh Devin manual-token enrollment, a physical SSH host, Linux, and Windows still require verification; these are not claimed as tested. diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 1e479ab7e25..557cf4af122 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -61,6 +61,8 @@ import { renderOpenCodeAccountsSection } from './accounts-pane-provider-setting-sections' import { renderMiniMaxAccountsSection } from './accounts-pane-minimax-section' +import { ManagedDataAccountsSection } from './ManagedDataAccountsSection' +import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' import { renderAccountsRemovalDialogs } from './accounts-pane-removal-dialogs' export { getAccountsPaneSearchEntries } @@ -370,6 +372,12 @@ export function AccountsPane({ clearMiniMaxCookie } const visibleSections = [ + !searchQuery || /opencode|devin|account/i.test(searchQuery) ? ( +
+ + +
+ ) : null, wslSupportedPlatform && !isRemoteAccountScope && matchesSettingsSearch(searchQuery, getAccountsLocationSearchEntries()) diff --git a/src/renderer/src/components/settings/ManagedDataAccountsSection.tsx b/src/renderer/src/components/settings/ManagedDataAccountsSection.tsx new file mode 100644 index 00000000000..0f0fdbecedd --- /dev/null +++ b/src/renderer/src/components/settings/ManagedDataAccountsSection.tsx @@ -0,0 +1,232 @@ +import { useEffect, useState } from 'react' +import { translate } from '@/i18n/i18n' +import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import type { + ManagedDataAccountProvider, + ManagedDataAccountsState +} from '../../../../shared/managed-account-types' +import { Button } from '../ui/button' +import { Badge } from '../ui/badge' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter +} from '../ui/dialog' + +type Snapshot = { opencode?: ManagedDataAccountsState; devin?: ManagedDataAccountsState } + +export function ManagedDataAccountsSection({ + provider, + target +}: { + provider: ManagedDataAccountProvider + target: RuntimeClientTarget +}): React.JSX.Element { + const [state, setState] = useState(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const [removeId, setRemoveId] = useState(null) + const [copied, setCopied] = useState(false) + const environmentId = target.kind === 'environment' ? target.environmentId : null + + useEffect(() => { + const controller = new AbortController() + const requestTarget: RuntimeClientTarget = environmentId + ? { kind: 'environment', environmentId } + : { kind: 'local' } + void callRuntimeRpc(requestTarget, 'accounts.listData', undefined, { + signal: controller.signal + }) + .then((snapshot) => { + if (!controller.signal.aborted) { + setState(snapshot[provider] ?? null) + } + }) + .catch((cause: unknown) => { + if (!controller.signal.aborted) { + setError(cause instanceof Error ? cause.message : String(cause)) + } + }) + return () => controller.abort() + }, [provider, environmentId]) + + async function refresh(): Promise { + setBusy(true) + setError(null) + try { + const snapshot = await callRuntimeRpc(target, 'accounts.listData') + setState(snapshot[provider] ?? null) + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)) + } finally { + setBusy(false) + } + } + + async function mutate(action: 'select' | 'remove', accountId: string | null): Promise { + setBusy(true) + setError(null) + try { + setState( + await callRuntimeRpc(target, `accounts.${action}Data`, { + provider, + accountId + }) + ) + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)) + } finally { + setBusy(false) + } + } + + const command = `orca account add --agent ${provider}` + return ( +
+

{provider === 'opencode' ? 'OpenCode' : 'Devin'}

+

+ {translate( + 'accounts.managedData.description', + 'Add accounts by running this command in a terminal on the Orca host. Selection applies to new explicit agent launches on that host; direct SSH relay and Windows-hosted WSL launches use their own credentials.' + )} +

+ {command} +
+ + +
+ {!state && !error && ( +

+ {translate( + 'accounts.managedData.upgrade', + 'If accounts do not appear, update or restart the Orca host.' + )} +

+ )} + {error && ( +

+ {error} +

+ )} + {state && ( + <> +
+ + {translate('accounts.managedData.system', 'System default')} + + +
+ {state.accounts.map((account) => ( +
+
+ {account.label} +

{account.integrations.join(', ')}

+
+
+ {state.activeAccountId === account.id ? ( + + {translate('accounts.managedData.active', 'Active')} + + ) : ( + + )} + +
+
+ ))} + + )} + { + if (!open) { + setRemoveId(null) + } + }} + > + + + + {translate('accounts.managedData.removeTitle', 'Remove managed account?')} + + + {translate( + 'accounts.managedData.removeDescription', + 'Stop agents using this profile first. Removal deletes its saved credentials and conversation data. Your system login stays unchanged.' + )} + + + + + + + + +
+ ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2f29e560803..c37adaa1122 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18742,5 +18742,21 @@ "addTypedPath": "Add", "noDirectories": "No folders added yet.", "removePath": "Remove {{name}}" + }, + "accounts": { + "managedData": { + "description": "Add accounts by running this command in a terminal on the Orca host. Selection applies to new explicit agent launches on that host; direct SSH relay and Windows-hosted WSL launches use their own credentials.", + "copied": "Copied", + "add": "Copy add account command", + "refresh": "Refresh accounts", + "upgrade": "If accounts do not appear, update or restart the Orca host.", + "system": "System default", + "active": "Active", + "select": "Select", + "remove": "Remove", + "removeTitle": "Remove managed account?", + "removeDescription": "Stop agents using this profile first. Removal deletes its saved credentials and conversation data. Your system login stays unchanged.", + "cancel": "Cancel" + } } } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 45afe6f04a0..40fa622722b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15550,5 +15550,21 @@ "tooLargeInDiff": "El archivo supera el límite de vista previa de {{limit}}. Cambia al modo fuente para ver las diferencias.", "renderAnyway": "Renderizar de todos modos" } + }, + "accounts": { + "managedData": { + "description": "Añade cuentas ejecutando este comando en una terminal del host de Orca. La selección se aplica a nuevos inicios del agente en ese host; las conexiones SSH directas y WSL alojado en Windows usan sus propias credenciales.", + "copied": "Copiado", + "add": "Copiar comando para añadir cuenta", + "refresh": "Actualizar cuentas", + "upgrade": "Si las cuentas no aparecen, actualiza o reinicia el host de Orca.", + "system": "Predeterminada del sistema", + "active": "Activa", + "select": "Seleccionar", + "remove": "Eliminar", + "removeTitle": "¿Eliminar la cuenta administrada?", + "removeDescription": "Detén primero los agentes que usan este perfil. Al eliminarlo se borran sus credenciales y conversaciones guardadas. Tu inicio de sesión del sistema no cambia.", + "cancel": "Cancelar" + } } } diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 85a1b64c154..46ee70baf12 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18657,5 +18657,21 @@ "addTypedPath": "Add", "noDirectories": "No folders added yet.", "removePath": "Remove {{name}}" + }, + "accounts": { + "managedData": { + "description": "Ajoutez des comptes en exécutant cette commande dans un terminal sur l’hôte Orca. La sélection s’applique aux nouveaux lancements sur cet hôte ; les connexions SSH directes et WSL sous Windows utilisent leurs propres identifiants.", + "copied": "Copié", + "add": "Copier la commande d’ajout de compte", + "refresh": "Actualiser les comptes", + "upgrade": "Si les comptes n’apparaissent pas, mettez à jour ou redémarrez l’hôte Orca.", + "system": "Compte système par défaut", + "active": "Actif", + "select": "Sélectionner", + "remove": "Supprimer", + "removeTitle": "Supprimer le compte géré ?", + "removeDescription": "Arrêtez d’abord les agents utilisant ce profil. Sa suppression efface les identifiants et conversations enregistrés. Votre connexion système reste inchangée.", + "cancel": "Annuler" + } } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 0bed9bab422..8fe5e4b07ab 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18657,5 +18657,21 @@ "addTypedPath": "Add", "noDirectories": "No folders added yet.", "removePath": "Remove {{name}}" + }, + "accounts": { + "managedData": { + "description": "Orcaホストのターミナルでこのコマンドを実行してアカウントを追加します。選択はそのホストでの新しいエージェント起動に適用されます。直接SSH接続とWindows上のWSLは独自の認証情報を使用します。", + "copied": "コピーしました", + "add": "アカウント追加コマンドをコピー", + "refresh": "アカウントを更新", + "upgrade": "アカウントが表示されない場合はOrcaホストを更新または再起動してください。", + "system": "システムの既定", + "active": "使用中", + "select": "選択", + "remove": "削除", + "removeTitle": "管理アカウントを削除しますか?", + "removeDescription": "まずこのプロファイルを使うエージェントを停止してください。削除すると保存済みの認証情報と会話データが消去されます。システムのログインには影響しません。", + "cancel": "キャンセル" + } } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a503c3347d8..ba445093524 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18657,5 +18657,21 @@ "addTypedPath": "Add", "noDirectories": "No folders added yet.", "removePath": "Remove {{name}}" + }, + "accounts": { + "managedData": { + "description": "Orca 호스트의 터미널에서 이 명령을 실행하여 계정을 추가하세요. 선택은 해당 호스트에서 새로 시작하는 에이전트에 적용됩니다. 직접 SSH 연결과 Windows에서 호스팅하는 WSL은 자체 자격 증명을 사용합니다.", + "copied": "복사됨", + "add": "계정 추가 명령 복사", + "refresh": "계정 새로 고침", + "upgrade": "계정이 나타나지 않으면 Orca 호스트를 업데이트하거나 다시 시작하세요.", + "system": "시스템 기본값", + "active": "활성", + "select": "선택", + "remove": "삭제", + "removeTitle": "관리 계정을 삭제할까요?", + "removeDescription": "먼저 이 프로필을 사용하는 에이전트를 중지하세요. 삭제하면 저장된 자격 증명과 대화 데이터가 지워집니다. 시스템 로그인은 변경되지 않습니다.", + "cancel": "취소" + } } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 63fc2b87de5..f443dc9acd5 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18657,5 +18657,21 @@ "addTypedPath": "Add", "noDirectories": "No folders added yet.", "removePath": "Remove {{name}}" + }, + "accounts": { + "managedData": { + "description": "在 Orca 主机的终端中运行此命令以添加账户。选择适用于该主机上新启动的代理;直接 SSH 连接和 Windows 托管的 WSL 使用各自的凭据。", + "copied": "已复制", + "add": "复制添加账户命令", + "refresh": "刷新账户", + "upgrade": "如果账户未显示,请更新或重启 Orca 主机。", + "system": "系统默认", + "active": "当前使用", + "select": "选择", + "remove": "移除", + "removeTitle": "移除托管账户?", + "removeDescription": "请先停止使用此配置的代理。移除将删除其保存的凭据和对话数据。系统登录不受影响。", + "cancel": "取消" + } } } From 3e7e6089d2219489322174e5e5558fc1d4f3b69a Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:28:27 -0700 Subject: [PATCH 03/15] Expose registered account roots to host transcript readers --- src/main/managed-data-accounts/service.test.ts | 14 ++++++++++++++ src/main/managed-data-accounts/service.ts | 16 +++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/main/managed-data-accounts/service.test.ts b/src/main/managed-data-accounts/service.test.ts index 76cc162ea4a..8b7a2ad4425 100644 --- a/src/main/managed-data-accounts/service.test.ts +++ b/src/main/managed-data-accounts/service.test.ts @@ -105,6 +105,20 @@ describe('managed data accounts', () => { expect(service.list('devin').accounts.map((account) => account.label)).toEqual(['One', 'Two']) }) + it('keeps registered transcript roots available when selection changes', async () => { + const first = await service.add('devin', source, 'One') + const firstEnvironment = service.launchEnvironment('devin') + await service.add('devin', source, 'Two') + const secondEnvironment = service.launchEnvironment('devin') + expect(service.transcriptEnvironments('devin')).toEqual([secondEnvironment, firstEnvironment]) + await service.select('devin', first.accounts[0].id) + expect(service.transcriptEnvironments('devin')).toEqual([firstEnvironment, secondEnvironment]) + await service.select('devin', null) + expect(service.transcriptEnvironments('devin')).toEqual([firstEnvironment, secondEnvironment]) + await service.remove('devin', first.accounts[0].id) + expect(service.transcriptEnvironments('devin')).toEqual([secondEnvironment]) + }) + it('rejects a credential symlink without touching its target', async () => { const original = join(source, 'devin', 'credentials.toml') const target = join(root, 'private.toml') diff --git a/src/main/managed-data-accounts/service.ts b/src/main/managed-data-accounts/service.ts index 0c053333f82..0191a83124b 100644 --- a/src/main/managed-data-accounts/service.ts +++ b/src/main/managed-data-accounts/service.ts @@ -106,7 +106,21 @@ export class ManagedDataAccountService { if (!state.activeAccountId) { return {} } - const directory = this.requireAccount(provider, state.activeAccountId) + return this.profileEnvironment(provider, state.activeAccountId) + } + + transcriptEnvironments(provider: ManagedDataAccountProvider): Record[] { + const state = this.list(provider) + const selected = state.accounts.filter((account) => account.id === state.activeAccountId) + const others = state.accounts.filter((account) => account.id !== state.activeAccountId) + return [...selected, ...others].map((account) => this.profileEnvironment(provider, account.id)) + } + + private profileEnvironment( + provider: ManagedDataAccountProvider, + accountId: string + ): Record { + const directory = this.requireAccount(provider, accountId) return { XDG_DATA_HOME: join(directory, 'data'), XDG_STATE_HOME: join(directory, 'state'), From ea87b366034bb456788c6140988f3c692b708d9a Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:31:05 -0700 Subject: [PATCH 04/15] Clarify managed profile provider flags --- src/cli/help.ts | 3 +++ src/cli/specs/account.test.ts | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/src/cli/help.ts b/src/cli/help.ts index c2153e665e4..eafe9753271 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -180,6 +180,9 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string { if (command === 'account add' && flag === 'agent') { return '--agent Account provider: claude, codex, opencode, or devin (default claude)' } + if (command.startsWith('account ') && flag === 'agent') { + return '--agent Account provider: opencode or devin' + } if (flag === 'key' && command === 'computer hotkey') { return '--key Modifier chord with one key, e.g. CmdOrCtrl+A' } diff --git a/src/cli/specs/account.test.ts b/src/cli/specs/account.test.ts index 27fd5568b36..91dc2cf48d5 100644 --- a/src/cli/specs/account.test.ts +++ b/src/cli/specs/account.test.ts @@ -50,4 +50,10 @@ describe('account command specs', () => { expect(descriptionColumn(help, 'agent')).toBe(descriptionColumn(help, 'json')) }) + + it('describes the supported providers for profile selection and removal', () => { + for (const command of ['account list', 'account select', 'account remove']) { + expect(formatCommandHelp(spec(command))).toContain('Account provider: opencode or devin') + } + }) }) From c89ecf1775c8c2b1143c6e42324d524cf4f5560f Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:34:47 -0700 Subject: [PATCH 05/15] Retain isolated Electron home in browser sidecars --- .../orcad/electron-serve-browser-process.test.ts | 12 +++++++++++- src/main/orcad/electron-serve-browser-process.ts | 8 ++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/main/orcad/electron-serve-browser-process.test.ts b/src/main/orcad/electron-serve-browser-process.test.ts index 94a8db3205d..383841709a6 100644 --- a/src/main/orcad/electron-serve-browser-process.test.ts +++ b/src/main/orcad/electron-serve-browser-process.test.ts @@ -139,6 +139,11 @@ describe('ElectronServeBrowserProcess start-up', () => { for (const key of ['ORCA_E2E_USER_DATA_DIR', 'ORCA_USER_DATA', 'ORCA_USER_DATA_PATH']) { vi.stubEnv(key, harnessRoot) } + const isolatedHome = join(harnessRoot, 'home') + vi.stubEnv('ORCA_E2E_HOME_DIR', isolatedHome) + vi.stubEnv('HOME', isolatedHome) + vi.stubEnv('XDG_DATA_HOME', join(isolatedHome, 'data')) + vi.stubEnv('XDG_STATE_HOME', join(isolatedHome, 'state')) const processHandle = await startProvider() @@ -156,9 +161,14 @@ 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']) { + for (const key of ['ORCA_USER_DATA', 'ORCA_USER_DATA_PATH']) { expect(spec.env).not.toHaveProperty(key) } + expect(spec.env?.ORCA_E2E_USER_DATA_DIR).toBe(userDataArg?.slice('--user-data-dir='.length)) + expect(spec.env?.ORCA_E2E_HOME_DIR).toBe(isolatedHome) + expect(spec.env?.HOME).toBe(isolatedHome) + expect(spec.env?.XDG_DATA_HOME).toBe(join(isolatedHome, 'data')) + expect(spec.env?.XDG_STATE_HOME).toBe(join(isolatedHome, 'state')) 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 c8c4409abc9..875c18fa8b5 100644 --- a/src/main/orcad/electron-serve-browser-process.ts +++ b/src/main/orcad/electron-serve-browser-process.ts @@ -52,7 +52,7 @@ async function reserveLoopbackPort(): Promise { }) return address.port } -function electronServeEnvironment(): NodeJS.ProcessEnv { +function electronServeEnvironment(userDataPath: string): NodeJS.ProcessEnv { const environment = { ...process.env } for (const key of [ 'ORCA_E2E_USER_DATA_DIR', @@ -72,6 +72,10 @@ function electronServeEnvironment(): NodeJS.ProcessEnv { ]) { delete environment[key] } + // Keep Electron's native home override active in isolated sidecars. + if (process.env.ORCA_E2E_USER_DATA_DIR || process.env.ORCA_E2E_HOME_DIR) { + environment.ORCA_E2E_USER_DATA_DIR = userDataPath + } return environment } @@ -115,7 +119,7 @@ export class ElectronServeBrowserProcess { '--serve-no-pairing', `--user-data-dir=${userDataPath}` ], - env: electronServeEnvironment() + env: electronServeEnvironment(userDataPath) }) this.child = child for (const stream of [child.stdout, child.stderr]) { From 4f9c569874f488d76aaf08870376a975d1b75f8a Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 02:01:46 -0700 Subject: [PATCH 06/15] Restore inherited account environment and preserve cleanup retries --- .../pty-subprocess/spawn-environment.ts | 5 +- src/main/ipc/pty/ipc/spawn-options.ts | 2 + src/main/ipc/pty/runtime/spawn-options.ts | 2 + .../launch-environment.test.ts | 94 +++++++++++++++++++ .../launch-environment.ts | 15 +++ .../managed-data-accounts/service.test.ts | 17 ++++ src/main/managed-data-accounts/service.ts | 21 +++-- .../electron-serve-browser-process.test.ts | 1 + .../orcad/electron-serve-browser-process.ts | 3 + .../providers/local-pty-spawn-environment.ts | 5 +- src/relay/pty-handler.ts | 22 ++++- .../managed-data-account-environment.ts | 78 +++++++++++++++ 12 files changed, 251 insertions(+), 14 deletions(-) create mode 100644 src/main/managed-data-accounts/launch-environment.test.ts create mode 100644 src/shared/managed-data-account-environment.ts diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts index a063ffb3dd6..d28c61752a7 100644 --- a/src/main/daemon/pty-subprocess/spawn-environment.ts +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -1,4 +1,5 @@ import { getLegacyOpenCodeEnvKeysToDelete } from '../../opencode/legacy-shared-config-dir' +import { restoreManagedDataAccountEnvironment } from '../../../shared/managed-data-account-environment' import { restoreOrStripOverlayEnv } from '../../../shared/agent-overlay-env' import { delimiter } from 'node:path' import { dropInheritedOrcaFishHistory } from '../../fish-history-session' @@ -166,8 +167,10 @@ function removeInheritedDevAgentHookEndpoint( /** A persistent daemon's inherited environment cannot supply ownership for a new pane. */ export function createDaemonPtyEnvironment(opts: PtySubprocessOptions): Record { + const inheritedEnv = stripInheritedBuildModeEnv(process.env) + restoreManagedDataAccountEnvironment(inheritedEnv) const env: Record = { - ...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), opts.env), + ...mergeGitConfigEnvProtocol(inheritedEnv, opts.env), TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'Orca', diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index d0a3ba8cef1..411cb14f287 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -1,4 +1,5 @@ import { getAppEnvironment } from '../../../../shared/app-environment' +import { getInheritedManagedDataAccountEnvKeysToDelete } from '../../../../shared/managed-data-account-environment' import { getLegacyOpenCodeEnvKeysToDelete } from '../../../opencode/legacy-shared-config-dir' import { isTuiAgent } from '../../../../shared/tui-agent-config' import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment' @@ -50,6 +51,7 @@ export async function buildPtyIpcSpawnOptions( // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.spawnEnv) : [], + ctx.isDaemonHostSpawn ? getInheritedManagedDataAccountEnvKeysToDelete(ctx.spawnEnv) : [], // The daemon must judge its own inherited value; main may have a different config. !args.connectionId && !ctx.isDaemonHostSpawn ? getLegacyOpenCodeEnvKeysToDelete(ctx.spawnEnv, getAppEnvironment().getPath('userData')) diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 5f64779b364..56697f94bad 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -1,4 +1,5 @@ import { getAppEnvironment } from '../../../../shared/app-environment' +import { getInheritedManagedDataAccountEnvKeysToDelete } from '../../../../shared/managed-data-account-environment' import { getLegacyOpenCodeEnvKeysToDelete } from '../../../opencode/legacy-shared-config-dir' import type { IPtyProvider, PtySpawnResult } from '../../../providers/types' import { LocalPtyProvider } from '../../../providers/local-pty-provider' @@ -79,6 +80,7 @@ export async function buildRuntimePtySpawnOptions( // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.env) : [], + ctx.isDaemonHostSpawn ? getInheritedManagedDataAccountEnvKeysToDelete(ctx.env) : [], // The daemon must judge its own inherited value; main may have a different config. !args.connectionId && !ctx.isDaemonHostSpawn ? getLegacyOpenCodeEnvKeysToDelete(ctx.env, getAppEnvironment().getPath('userData')) diff --git a/src/main/managed-data-accounts/launch-environment.test.ts b/src/main/managed-data-accounts/launch-environment.test.ts new file mode 100644 index 00000000000..85e5a669626 --- /dev/null +++ b/src/main/managed-data-accounts/launch-environment.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { + captureManagedDataAccountOriginalEnvironment, + restoreManagedDataAccountEnvironment, + getInheritedManagedDataAccountEnvKeysToDelete +} from '../../shared/managed-data-account-environment' +import { createDaemonPtyEnvironment } from '../daemon/pty-subprocess/spawn-environment' + +const selected = vi.hoisted(() => { + const value: Record = {} + return { value } +}) +vi.mock('./service', () => ({ + getManagedDataAccountService: () => ({ launchEnvironment: () => selected.value }) +})) +import { applyManagedDataAccountEnvironment } from './launch-environment' + +function inheritedProfile(): Record { + const env = { + XDG_DATA_HOME: join(tmpdir(), 'user-data'), + OPENCODE_DB: 'user.db', + OPENCODE_AUTH_CONTENT: 'user-config' + } + captureManagedDataAccountOriginalEnvironment(env) + return { + ...env, + XDG_DATA_HOME: join(tmpdir(), 'old-account', 'data'), + XDG_STATE_HOME: join(tmpdir(), 'old-account', 'state'), + OPENCODE_DB: 'opencode.db', + OPENCODE_AUTH_CONTENT: '', + ORCA_DATA_ACCOUNT_DATA_HOME: join(tmpdir(), 'old-account', 'data'), + ORCA_DATA_ACCOUNT_STATE_HOME: join(tmpdir(), 'old-account', 'state'), + ORCA_DATA_ACCOUNT_PROVIDER: 'opencode' + } +} + +afterEach(() => { + vi.unstubAllEnvs() + selected.value = {} +}) + +describe('managed account inherited environment', () => { + it.each([ + { launchAgent: 'opencode' as const }, + { launchAgent: 'claude' as const }, + { launchAgent: 'opencode' as const, isWsl: true } + ])('restores user defaults before eligibility and system selection: %o', (options) => { + const env = inheritedProfile() + for (const [key, value] of Object.entries(env)) { + vi.stubEnv(key, value) + } + applyManagedDataAccountEnvironment(env, options) + expect(env.XDG_DATA_HOME).toBe(join(tmpdir(), 'user-data')) + expect(env.XDG_STATE_HOME).toBeUndefined() + expect(env.OPENCODE_DB).toBe('user.db') + expect(env.OPENCODE_AUTH_CONTENT).toBe('user-config') + expect(Object.keys(env).some((key) => key.startsWith('ORCA_DATA_ACCOUNT'))).toBe(false) + const final = createDaemonPtyEnvironment({ sessionId: 'test', cols: 80, rows: 24, env }) + expect(final.XDG_DATA_HOME).toBe(join(tmpdir(), 'user-data')) + expect(final.XDG_STATE_HOME).toBeUndefined() + expect(final.ORCA_DATA_ACCOUNT_DATA_HOME).toBeUndefined() + expect(getInheritedManagedDataAccountEnvKeysToDelete(env)).toContain('XDG_STATE_HOME') + }) + + it('keeps a fresh selection after the daemon restores its own parent profile', () => { + for (const [key, value] of Object.entries(inheritedProfile())) { + vi.stubEnv(key, value) + } + selected.value = { + XDG_DATA_HOME: join(tmpdir(), 'new-account', 'data'), + XDG_STATE_HOME: join(tmpdir(), 'new-account', 'state'), + OPENCODE_DB: 'opencode.db', + OPENCODE_AUTH_CONTENT: '' + } + const env: Record = {} + applyManagedDataAccountEnvironment(env, { launchAgent: 'opencode' }) + const final = createDaemonPtyEnvironment({ sessionId: 'test', cols: 80, rows: 24, env }) + expect(final.XDG_DATA_HOME).toBe(selected.value.XDG_DATA_HOME) + restoreManagedDataAccountEnvironment(final) + expect(final.XDG_DATA_HOME).toBe(join(tmpdir(), 'user-data')) + expect(final.OPENCODE_DB).toBe('user.db') + }) + + it('scrubs client-owned profile paths without restoring desktop paths on a relay', () => { + const env = inheritedProfile() + restoreManagedDataAccountEnvironment(env, false) + expect(env.XDG_DATA_HOME).toBeUndefined() + expect(env.XDG_STATE_HOME).toBeUndefined() + expect(env.OPENCODE_DB).toBeUndefined() + expect(Object.keys(env).some((key) => key.startsWith('ORCA_DATA_ACCOUNT'))).toBe(false) + }) +}) diff --git a/src/main/managed-data-accounts/launch-environment.ts b/src/main/managed-data-accounts/launch-environment.ts index 382addedef3..8546b001cf9 100644 --- a/src/main/managed-data-accounts/launch-environment.ts +++ b/src/main/managed-data-accounts/launch-environment.ts @@ -4,11 +4,25 @@ import { } from '../../shared/command-token-scanner' import type { TuiAgent } from '../../shared/tui-agent' import { getManagedDataAccountService } from './service' +import { + captureManagedDataAccountOriginalEnvironment, + MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS, + restoreManagedDataAccountEnvironment +} from '../../shared/managed-data-account-environment' export function applyManagedDataAccountEnvironment( environment: Record, options: { launchAgent?: TuiAgent; launchCommand?: string; isWsl?: boolean } ): void { + restoreManagedDataAccountEnvironment(environment) + const inherited = { ...process.env } + restoreManagedDataAccountEnvironment(inherited) + for (const key of MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS) { + const value = inherited[key] + if (environment[key] === undefined && value !== undefined) { + environment[key] = value + } + } if (options.isWsl) { return } @@ -27,6 +41,7 @@ export function applyManagedDataAccountEnvironment( if (!selected.XDG_DATA_HOME) { return } + captureManagedDataAccountOriginalEnvironment(environment) Object.assign(environment, selected) environment.ORCA_DATA_ACCOUNT_DATA_HOME = selected.XDG_DATA_HOME environment.ORCA_DATA_ACCOUNT_STATE_HOME = selected.XDG_STATE_HOME diff --git a/src/main/managed-data-accounts/service.test.ts b/src/main/managed-data-accounts/service.test.ts index 8b7a2ad4425..d9ad4cd2c12 100644 --- a/src/main/managed-data-accounts/service.test.ts +++ b/src/main/managed-data-accounts/service.test.ts @@ -42,6 +42,23 @@ function openCodeSource(sessionTable = 'session'): void { } describe('managed data accounts', () => { + it('retains selected account metadata after locked cleanup and permits retry', async () => { + let locked = true + service = new ManagedDataAccountService(join(root, 'managed'), (directory) => { + if (locked) { + throw new Error('file locked') + } + rmSync(directory, { recursive: true, force: true }) + }) + const before = await service.add('devin', source, 'Work') + await expect(service.remove('devin', before.accounts[0].id)).rejects.toThrow('file locked') + expect(service.list('devin')).toEqual(before) + expect(service.launchEnvironment('devin').XDG_DATA_HOME).toBeTruthy() + locked = false + await service.remove('devin', before.accounts[0].id) + expect(service.list('devin')).toEqual({ accounts: [], activeAccountId: null }) + }) + 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 diff --git a/src/main/managed-data-accounts/service.ts b/src/main/managed-data-accounts/service.ts index 0191a83124b..4549795f6eb 100644 --- a/src/main/managed-data-accounts/service.ts +++ b/src/main/managed-data-accounts/service.ts @@ -28,7 +28,11 @@ export class ManagedDataAccountService { private pending: Promise = Promise.resolve() private readonly listeners = new Set<() => void>() - constructor(private readonly root: string) {} + constructor( + private readonly root: string, + private readonly removeDirectory: (directory: string) => void = (directory) => + rmSync(directory, { recursive: true, force: true }) + ) {} list(provider: ManagedDataAccountProvider): ManagedDataAccountsState { const path = join(this.root, provider, 'accounts.json') @@ -89,15 +93,18 @@ export class ManagedDataAccountService { ): Promise { return this.mutate(async () => { const state = this.list(provider) - this.requireAccount(provider, accountId) - const result = this.persist(provider, { + if (!state.accounts.some((account) => account.id === accountId)) { + throw new Error('Managed account not found.') + } + const directory = join(this.root, provider, accountId) + if (existsSync(directory)) { + this.assertOwned(directory) + } + this.removeDirectory(directory) + return 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 }) } diff --git a/src/main/orcad/electron-serve-browser-process.test.ts b/src/main/orcad/electron-serve-browser-process.test.ts index 383841709a6..d3ea8c87e23 100644 --- a/src/main/orcad/electron-serve-browser-process.test.ts +++ b/src/main/orcad/electron-serve-browser-process.test.ts @@ -169,6 +169,7 @@ describe('ElectronServeBrowserProcess start-up', () => { expect(spec.env?.HOME).toBe(isolatedHome) expect(spec.env?.XDG_DATA_HOME).toBe(join(isolatedHome, 'data')) expect(spec.env?.XDG_STATE_HOME).toBe(join(isolatedHome, 'state')) + expect(args).toEqual(expect.arrayContaining(['--password-store=basic', '--use-mock-keychain'])) 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 875c18fa8b5..d55020551ef 100644 --- a/src/main/orcad/electron-serve-browser-process.ts +++ b/src/main/orcad/electron-serve-browser-process.ts @@ -117,6 +117,9 @@ export class ElectronServeBrowserProcess { String(port), '--serve-json', '--serve-no-pairing', + ...(process.env.ORCA_E2E_USER_DATA_DIR || process.env.ORCA_E2E_HOME_DIR + ? ['--password-store=basic', '--use-mock-keychain'] + : []), `--user-data-dir=${userDataPath}` ], env: electronServeEnvironment(userDataPath) diff --git a/src/main/providers/local-pty-spawn-environment.ts b/src/main/providers/local-pty-spawn-environment.ts index 645fcacb2d0..2d4694ba5ea 100644 --- a/src/main/providers/local-pty-spawn-environment.ts +++ b/src/main/providers/local-pty-spawn-environment.ts @@ -1,4 +1,5 @@ import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env' +import { restoreManagedDataAccountEnvironment } from '../../shared/managed-data-account-environment' import { ORCA_IMAGE_PROTOCOL_ENV, ORCA_IMAGE_PROTOCOL_VALUE @@ -22,8 +23,10 @@ export function buildLocalPtySpawnEnvironment(args: { plan: LocalPtyLaunchPlan }): Record | Promise> { const { id, spawn, getOptions, plan } = args + const inheritedEnv = stripInheritedBuildModeEnv(process.env) + restoreManagedDataAccountEnvironment(inheritedEnv) const spawnEnv: Record = { - ...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), spawn.env), + ...mergeGitConfigEnvProtocol(inheritedEnv, spawn.env), TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'Orca', diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index fbad4a212a0..46acda30720 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -1,5 +1,6 @@ /* oxlint-disable max-lines */ import { resolveSynchronizedOutputSafeSplit } from '../shared/terminal-synchronized-output-scan' +import { restoreManagedDataAccountEnvironment } from '../shared/managed-data-account-environment' import { createTerminalTitleTracker } from '../shared/terminal-output-side-effects' import { getDecorativeTitleGateKey } from '../shared/agent-decorative-title-signature' import { FreebuffStatusProjection } from './freebuff-status-projection' @@ -822,9 +823,13 @@ export class PtyHandler { }, envToDelete: readonly string[] = [] ): Promise> { - const baseEnv = mergeGitConfigEnvProtocol( + const inheritedEnv = stripInheritedBuildModeEnv(process.env) + restoreManagedDataAccountEnvironment(inheritedEnv) + const explicitEnv = { ...rendererEnv } + restoreManagedDataAccountEnvironment(explicitEnv, false) + const mergedEnv = mergeGitConfigEnvProtocol( { - ...stripInheritedBuildModeEnv(process.env), + ...inheritedEnv, TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'Orca', @@ -832,8 +837,11 @@ export class PtyHandler { rendererEnv?.ORCA_APP_VERSION || process.env.ORCA_APP_VERSION || '0.0.0-dev', FORCE_HYPERLINK: '1' }, - rendererEnv - ) as Record + explicitEnv + ) + const baseEnv: Record = Object.fromEntries( + Object.entries(mergedEnv).filter(([, value]) => typeof value === 'string') + ) const augmented: Record = {} for (const augmenter of this.envAugmenters) { try { @@ -844,7 +852,11 @@ export class PtyHandler { ) } } - const result = mergeGitConfigEnvProtocol(baseEnv, augmented) as Record + const result: Record = Object.fromEntries( + Object.entries(mergeGitConfigEnvProtocol(baseEnv, augmented)).filter( + ([, value]) => typeof value === 'string' + ) + ) result[ORCA_IMAGE_PROTOCOL_ENV] = ORCA_IMAGE_PROTOCOL_VALUE // Why: an older client may not ask a newly upgraded relay to delete inherited shim state. stripLegacyTerminalShimEnv(result, process.platform) diff --git a/src/shared/managed-data-account-environment.ts b/src/shared/managed-data-account-environment.ts new file mode 100644 index 00000000000..8bfeca0af8d --- /dev/null +++ b/src/shared/managed-data-account-environment.ts @@ -0,0 +1,78 @@ +import { z } from 'zod' + +const originalEnvironment = z.object({ + XDG_DATA_HOME: z.string().nullable(), + XDG_STATE_HOME: z.string().nullable(), + OPENCODE_AUTH_CONTENT: z.string().nullable(), + OPENCODE_DB: z.string().nullable() +}) +const ORIGINAL_ENV = 'ORCA_DATA_ACCOUNT_ORIGINAL_ENV' +export const MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS = [ + 'XDG_DATA_HOME', + 'XDG_STATE_HOME', + 'OPENCODE_AUTH_CONTENT', + 'OPENCODE_DB' +] as const + +export function getInheritedManagedDataAccountEnvKeysToDelete( + environment: Record | undefined +): string[] { + return [ + ...MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS, + 'ORCA_DATA_ACCOUNT_DATA_HOME', + 'ORCA_DATA_ACCOUNT_STATE_HOME', + 'ORCA_DATA_ACCOUNT_PROVIDER', + ORIGINAL_ENV + ].filter((key) => environment?.[key] === undefined) +} + +export function captureManagedDataAccountOriginalEnvironment( + environment: Record +): void { + environment[ORIGINAL_ENV] = JSON.stringify( + Object.fromEntries( + MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS.map((key) => [key, environment[key] ?? null]) + ) + ) +} + +export function restoreManagedDataAccountEnvironment( + environment: Record, + restoreOriginal = true +): void { + let original: z.infer | undefined + try { + const parsed = originalEnvironment.safeParse(JSON.parse(environment[ORIGINAL_ENV] ?? 'null')) + if (restoreOriginal && parsed.success) { + original = parsed.data + } + } catch { + // Older panes have no baseline snapshot; strip only their owned overrides. + } + function restore( + key: (typeof MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS)[number], + ownedValue: string | undefined + ): void { + if (ownedValue === undefined || environment[key] !== ownedValue) { + return + } + const value = original?.[key] + if (typeof value === 'string') { + environment[key] = value + } else { + delete environment[key] + } + } + if (environment.ORCA_DATA_ACCOUNT_DATA_HOME) { + restore('XDG_DATA_HOME', environment.ORCA_DATA_ACCOUNT_DATA_HOME) + restore('XDG_STATE_HOME', environment.ORCA_DATA_ACCOUNT_STATE_HOME) + if (environment.ORCA_DATA_ACCOUNT_PROVIDER === 'opencode') { + restore('OPENCODE_AUTH_CONTENT', '') + restore('OPENCODE_DB', 'opencode.db') + } + } + delete environment.ORCA_DATA_ACCOUNT_DATA_HOME + delete environment.ORCA_DATA_ACCOUNT_STATE_HOME + delete environment.ORCA_DATA_ACCOUNT_PROVIDER + delete environment[ORIGINAL_ENV] +} From 889e4aa6781b6b3a34f7534b164c919ca02a695c Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 02:07:13 -0700 Subject: [PATCH 07/15] Check relay environment values before merging --- src/relay/pty-handler.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 46acda30720..199cff6379b 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -840,7 +840,9 @@ export class PtyHandler { explicitEnv ) const baseEnv: Record = Object.fromEntries( - Object.entries(mergedEnv).filter(([, value]) => typeof value === 'string') + Object.entries(mergedEnv).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) ) const augmented: Record = {} for (const augmenter of this.envAugmenters) { @@ -854,7 +856,7 @@ export class PtyHandler { } const result: Record = Object.fromEntries( Object.entries(mergeGitConfigEnvProtocol(baseEnv, augmented)).filter( - ([, value]) => typeof value === 'string' + (entry): entry is [string, string] => typeof entry[1] === 'string' ) ) result[ORCA_IMAGE_PROTOCOL_ENV] = ORCA_IMAGE_PROTOCOL_VALUE From d1abb6034d8b83c82e417f6c8c72a4daa4b72c36 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 04:05:48 -0700 Subject: [PATCH 08/15] Consolidate managed account type imports --- src/main/runtime/runtime-account-controller.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main/runtime/runtime-account-controller.ts b/src/main/runtime/runtime-account-controller.ts index e4f1cbfe8b5..c824301e5a5 100644 --- a/src/main/runtime/runtime-account-controller.ts +++ b/src/main/runtime/runtime-account-controller.ts @@ -2,6 +2,8 @@ import type { ClaudeAccountService } from '../claude-accounts/service' import { hasAppEnvironment } from '../../shared/app-environment' import { getManagedDataAccountService } from '../managed-data-accounts/service' import type { + ClaudeRateLimitAccountsState, + CodexRateLimitAccountsState, ManagedDataAccountProvider, ManagedDataAccountsState } from '../../shared/managed-account-types' @@ -11,10 +13,6 @@ import type { } from '../codex-accounts/service' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { RateLimitService } from '../rate-limits/service' -import type { - ClaudeRateLimitAccountsState, - CodexRateLimitAccountsState -} from '../../shared/managed-account-types' import type { CodexRateLimitResetOutcome, RateLimitState } from '../../shared/rate-limit-types' import type { CodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope' import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' From d10f8b02afc5fd7efcaa6a4e281859edf01cfdb3 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 05:54:17 -0700 Subject: [PATCH 09/15] fix(accounts): use existing localized provider names Align the new Japanese account copy with the existing catalog repair policy. --- .../src/components/settings/ManagedDataAccountsSection.tsx | 6 +++++- src/renderer/src/i18n/locales/ja.json | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/settings/ManagedDataAccountsSection.tsx b/src/renderer/src/components/settings/ManagedDataAccountsSection.tsx index 0f0fdbecedd..181a572a594 100644 --- a/src/renderer/src/components/settings/ManagedDataAccountsSection.tsx +++ b/src/renderer/src/components/settings/ManagedDataAccountsSection.tsx @@ -86,7 +86,11 @@ export function ManagedDataAccountsSection({ const command = `orca account add --agent ${provider}` return (
-

{provider === 'opencode' ? 'OpenCode' : 'Devin'}

+

+ {provider === 'opencode' + ? translate('auto.lib.agent.catalog.e7a4ca5103', 'OpenCode') + : translate('auto.lib.agent.catalog.fc80296033', 'Devin')} +

{translate( 'accounts.managedData.description', diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8fe5e4b07ab..38db9a3923b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18660,7 +18660,7 @@ }, "accounts": { "managedData": { - "description": "Orcaホストのターミナルでこのコマンドを実行してアカウントを追加します。選択はそのホストでの新しいエージェント起動に適用されます。直接SSH接続とWindows上のWSLは独自の認証情報を使用します。", + "description": "Orca ホストのターミナルでこのコマンドを実行してアカウントを追加します。選択はそのホストでの新規 Agent 起動に適用されます。直接 SSH 接続とWindows上の WSL は独自の認証情報を使用します。", "copied": "コピーしました", "add": "アカウント追加コマンドをコピー", "refresh": "アカウントを更新", @@ -18670,7 +18670,7 @@ "select": "選択", "remove": "削除", "removeTitle": "管理アカウントを削除しますか?", - "removeDescription": "まずこのプロファイルを使うエージェントを停止してください。削除すると保存済みの認証情報と会話データが消去されます。システムのログインには影響しません。", + "removeDescription": "まずこのプロファイルを使う Agent を停止してください。削除すると保存済みの認証情報と会話データが消去されます。システムのログインには影響しません。", "cancel": "キャンセル" } } From 8a95b0cf3acaf34ad47d7efbb0ea28cc0570f713 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 06:31:43 -0700 Subject: [PATCH 10/15] Keep managed account baselines private to the execution host --- .../opencode-hook-installation.test.ts | 2 +- src/main/ipc/pty/ipc/spawn-options.ts | 2 - ...ged-data-account-spawn-environment.test.ts | 179 ++++++++++++++ src/main/ipc/pty/runtime/spawn-options.ts | 2 - .../launch-environment.test.ts | 24 +- .../launch-environment.ts | 15 +- .../original-environment.test.ts | 231 ++++++++++++++++++ src/main/managed-data-accounts/service.ts | 74 ++++++ .../managed-data-account-environment.ts | 59 +++-- 9 files changed, 537 insertions(+), 51 deletions(-) create mode 100644 src/main/ipc/pty/managed-data-account-spawn-environment.test.ts create mode 100644 src/main/managed-data-accounts/original-environment.test.ts diff --git a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts index 8a9114eb942..a52a9d70360 100644 --- a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts +++ b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts @@ -12,7 +12,7 @@ import type { BuildPtyHostEnvOptions } from './types' const fixture = vi.hoisted(() => ({ userData: '', guestOverlay: '' })) vi.mock('../../../../shared/app-environment', () => ({ - getAppEnvironment: () => ({ getPath: () => fixture.userData }) + getAppEnvironment: () => ({ getPath: () => fixture.userData, onWillQuit: vi.fn() }) })) vi.mock('../../../agent-hooks/server', () => ({ agentHookServer: { buildPtyEnv: () => ({ ORCA_AGENT_HOOK_PORT: '12345' }) } diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index 411cb14f287..d0a3ba8cef1 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -1,5 +1,4 @@ import { getAppEnvironment } from '../../../../shared/app-environment' -import { getInheritedManagedDataAccountEnvKeysToDelete } from '../../../../shared/managed-data-account-environment' import { getLegacyOpenCodeEnvKeysToDelete } from '../../../opencode/legacy-shared-config-dir' import { isTuiAgent } from '../../../../shared/tui-agent-config' import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment' @@ -51,7 +50,6 @@ export async function buildPtyIpcSpawnOptions( // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.spawnEnv) : [], - ctx.isDaemonHostSpawn ? getInheritedManagedDataAccountEnvKeysToDelete(ctx.spawnEnv) : [], // The daemon must judge its own inherited value; main may have a different config. !args.connectionId && !ctx.isDaemonHostSpawn ? getLegacyOpenCodeEnvKeysToDelete(ctx.spawnEnv, getAppEnvironment().getPath('userData')) diff --git a/src/main/ipc/pty/managed-data-account-spawn-environment.test.ts b/src/main/ipc/pty/managed-data-account-spawn-environment.test.ts new file mode 100644 index 00000000000..ed13aa02de9 --- /dev/null +++ b/src/main/ipc/pty/managed-data-account-spawn-environment.test.ts @@ -0,0 +1,179 @@ +import { randomUUID, createHash } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { createDaemonPtyEnvironment } from '../../daemon/pty-subprocess/spawn-environment' +import { applyManagedDataAccountEnvironment } from '../../managed-data-accounts/launch-environment' +import { buildPtyIpcSpawnOptions } from './ipc/spawn-options' +import { createPtyIpcSpawnState } from './ipc/spawn-state' +import type { PtySpawnIpcDeps } from './ipc/spawn-types' +import { buildRuntimePtySpawnOptions } from './runtime/spawn-options' +import { createRuntimePtySpawnState } from './runtime/spawn-state' +import type { PtyRuntimeControllerDeps } from './runtime/controller-deps' +import type * as ServiceModule from '../../managed-data-accounts/service' +import { + MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS, + restoreManagedDataAccountEnvironment +} from '../../../shared/managed-data-account-environment' + +const selected = vi.hoisted(() => { + const value: Record = {} + return { value } +}) +vi.mock('../../managed-data-accounts/service', async (importOriginal) => { + const actual = await importOriginal() + const service = new actual.ManagedDataAccountService('test-managed-root') + vi.spyOn(service, 'launchEnvironment').mockImplementation(() => selected.value) + return { ...actual, getManagedDataAccountService: () => service } +}) + +beforeEach(() => { + for (const key of [ + ...MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS, + 'ORCA_DATA_ACCOUNT_DATA_HOME', + 'ORCA_DATA_ACCOUNT_STATE_HOME', + 'ORCA_DATA_ACCOUNT_PROVIDER', + 'ORCA_DATA_ACCOUNT_ORIGINAL_ENV' + ]) { + vi.stubEnv(key, undefined) + } + selected.value = {} +}) +afterEach(() => vi.unstubAllEnvs()) + +function hash(value: string | undefined): string | undefined { + return value === undefined ? undefined : createHash('sha256').update(value).digest('hex') +} + +async function spawnDeletions( + route: string, + env: Record, + envToDelete?: string[] +): Promise { + const args = { cols: 80, rows: 24, envToDelete } + if (route === 'renderer') { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: option construction with no workspace reads no required dependency methods. + const ctx = createPtyIpcSpawnState({} as PtySpawnIpcDeps, args) + ctx.env = env + ctx.isDaemonHostSpawn = true + await buildPtyIpcSpawnOptions(ctx) + ctx.finishTerminalInstall() + return ctx.spawnOptions.envToDelete ?? [] + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: option construction with no workspace reads no required dependency methods. + const ctx = createRuntimePtySpawnState({} as PtyRuntimeControllerDeps, args) + ctx.env = env + ctx.isDaemonHostSpawn = true + await buildRuntimePtySpawnOptions(ctx) + ctx.finishTerminalInstall() + return ctx.spawnOptions.envToDelete ?? [] +} + +describe.each(['renderer', 'runtime'])('%s managed account daemon environment', (route) => { + it('honors explicit user deletions and preserves unknown daemon metadata', async () => { + const env: Record = {} + const envToDelete = await spawnDeletions(route, env, ['XDG_STATE_HOME']) + const inherited = { + XDG_DATA_HOME: join(tmpdir(), 'daemon-data'), + XDG_STATE_HOME: join(tmpdir(), 'daemon-state'), + OPENCODE_DB: 'daemon.db', + ORCA_DATA_ACCOUNT_DATA_HOME: join(tmpdir(), 'future-profile'), + ORCA_DATA_ACCOUNT_PROVIDER: 'future-provider', + ORCA_DATA_ACCOUNT_ORIGINAL_ENV: '{"future":"opaque"}', + ORCA_DATA_ACCOUNT_FUTURE_METADATA: 'opaque-metadata' + } + for (const [key, value] of Object.entries(inherited)) { + vi.stubEnv(key, value) + } + const child = createDaemonPtyEnvironment({ + sessionId: 'pane', + cols: 80, + rows: 24, + env, + envToDelete + }) + expect(child.XDG_STATE_HOME).toBeUndefined() + for (const [key, value] of Object.entries(inherited)) { + if (key !== 'XDG_STATE_HOME') { + expect(child[key]).toBe(value) + } + } + }) + + it('preserves independent daemon defaults when main has no baseline or owned markers', async () => { + const env: Record = {} + applyManagedDataAccountEnvironment(env, { launchAgent: 'opencode' }) + const envToDelete = await spawnDeletions(route, env) + const defaults = { + XDG_DATA_HOME: join(tmpdir(), 'persistent-daemon', 'data'), + XDG_STATE_HOME: join(tmpdir(), 'persistent-daemon', 'state'), + OPENCODE_DB: 'daemon.db', + OPENCODE_AUTH_CONTENT: JSON.stringify({ fixture: { key: randomUUID() } }) + } + for (const [key, value] of Object.entries(defaults)) { + vi.stubEnv(key, value) + } + const child = createDaemonPtyEnvironment({ + sessionId: 'pane', + cols: 80, + rows: 24, + env, + envToDelete + }) + for (const [key, value] of Object.entries(defaults)) { + expect(hash(child[key]) === hash(value)).toBe(true) + } + }) + + it('keeps inline System authentication out of every selected child marker', async () => { + const secret = randomUUID() + const baseline = JSON.stringify({ fixture: { key: secret } }) + vi.stubEnv('OPENCODE_AUTH_CONTENT', baseline) + selected.value = { + XDG_DATA_HOME: join(tmpdir(), 'selected-account', 'data'), + XDG_STATE_HOME: join(tmpdir(), 'selected-account', 'state'), + OPENCODE_AUTH_CONTENT: '', + OPENCODE_DB: 'opencode.db' + } + const env: Record = {} + applyManagedDataAccountEnvironment(env, { launchAgent: 'opencode' }) + const envToDelete = await spawnDeletions(route, env) + const child = createDaemonPtyEnvironment({ + sessionId: 'pane', + cols: 80, + rows: 24, + env, + envToDelete + }) + expect(child.OPENCODE_AUTH_CONTENT).toBe('') + expect(Object.values(child).some((value) => value.includes(secret))).toBe(false) + selected.value = {} + vi.stubEnv('OPENCODE_AUTH_CONTENT', undefined) + const copied = { ...child } + applyManagedDataAccountEnvironment(copied, { launchAgent: 'opencode' }) + expect(hash(copied.OPENCODE_AUTH_CONTENT) === hash(baseline)).toBe(true) + for (const key of MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS) { + vi.stubEnv(key, child[key]) + } + for (const key of [ + 'ORCA_DATA_ACCOUNT_DATA_HOME', + 'ORCA_DATA_ACCOUNT_STATE_HOME', + 'ORCA_DATA_ACCOUNT_PROVIDER', + 'ORCA_DATA_ACCOUNT_ORIGINAL_ENV' + ]) { + vi.stubEnv(key, child[key]) + } + const systemDeletions = await spawnDeletions(route, copied) + const systemChild = createDaemonPtyEnvironment({ + sessionId: 'pane', + cols: 80, + rows: 24, + env: copied, + envToDelete: systemDeletions + }) + expect(hash(systemChild.OPENCODE_AUTH_CONTENT) === hash(baseline)).toBe(true) + expect(systemChild.ORCA_DATA_ACCOUNT_DATA_HOME).toBeUndefined() + restoreManagedDataAccountEnvironment(copied) + expect(hash(copied.OPENCODE_AUTH_CONTENT) === hash(baseline)).toBe(true) + }) +}) diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 56697f94bad..5f64779b364 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -1,5 +1,4 @@ import { getAppEnvironment } from '../../../../shared/app-environment' -import { getInheritedManagedDataAccountEnvKeysToDelete } from '../../../../shared/managed-data-account-environment' import { getLegacyOpenCodeEnvKeysToDelete } from '../../../opencode/legacy-shared-config-dir' import type { IPtyProvider, PtySpawnResult } from '../../../providers/types' import { LocalPtyProvider } from '../../../providers/local-pty-provider' @@ -80,7 +79,6 @@ export async function buildRuntimePtySpawnOptions( // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.env) : [], - ctx.isDaemonHostSpawn ? getInheritedManagedDataAccountEnvKeysToDelete(ctx.env) : [], // The daemon must judge its own inherited value; main may have a different config. !args.connectionId && !ctx.isDaemonHostSpawn ? getLegacyOpenCodeEnvKeysToDelete(ctx.env, getAppEnvironment().getPath('userData')) diff --git a/src/main/managed-data-accounts/launch-environment.test.ts b/src/main/managed-data-accounts/launch-environment.test.ts index 85e5a669626..be6be25b175 100644 --- a/src/main/managed-data-accounts/launch-environment.test.ts +++ b/src/main/managed-data-accounts/launch-environment.test.ts @@ -1,21 +1,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { - captureManagedDataAccountOriginalEnvironment, - restoreManagedDataAccountEnvironment, - getInheritedManagedDataAccountEnvKeysToDelete -} from '../../shared/managed-data-account-environment' +import { restoreManagedDataAccountEnvironment } from '../../shared/managed-data-account-environment' import { createDaemonPtyEnvironment } from '../daemon/pty-subprocess/spawn-environment' +import type * as ServiceModule from './service' const selected = vi.hoisted(() => { const value: Record = {} return { value } }) -vi.mock('./service', () => ({ - getManagedDataAccountService: () => ({ launchEnvironment: () => selected.value }) -})) +vi.mock('./service', async (importOriginal) => { + const actual = await importOriginal() + const service = new actual.ManagedDataAccountService('test-managed-root') + vi.spyOn(service, 'launchEnvironment').mockImplementation(() => selected.value) + return { ...actual, getManagedDataAccountService: () => service } +}) import { applyManagedDataAccountEnvironment } from './launch-environment' +import { getManagedDataAccountService } from './service' function inheritedProfile(): Record { const env = { @@ -23,7 +24,11 @@ function inheritedProfile(): Record { OPENCODE_DB: 'user.db', OPENCODE_AUTH_CONTENT: 'user-config' } - captureManagedDataAccountOriginalEnvironment(env) + getManagedDataAccountService().captureOriginalEnvironment(env, { + XDG_DATA_HOME: join(tmpdir(), 'old-account', 'data'), + XDG_STATE_HOME: join(tmpdir(), 'old-account', 'state'), + OPENCODE_AUTH_CONTENT: '' + }) return { ...env, XDG_DATA_HOME: join(tmpdir(), 'old-account', 'data'), @@ -61,7 +66,6 @@ describe('managed account inherited environment', () => { expect(final.XDG_DATA_HOME).toBe(join(tmpdir(), 'user-data')) expect(final.XDG_STATE_HOME).toBeUndefined() expect(final.ORCA_DATA_ACCOUNT_DATA_HOME).toBeUndefined() - expect(getInheritedManagedDataAccountEnvKeysToDelete(env)).toContain('XDG_STATE_HOME') }) it('keeps a fresh selection after the daemon restores its own parent profile', () => { diff --git a/src/main/managed-data-accounts/launch-environment.ts b/src/main/managed-data-accounts/launch-environment.ts index 8546b001cf9..51a44e64e26 100644 --- a/src/main/managed-data-accounts/launch-environment.ts +++ b/src/main/managed-data-accounts/launch-environment.ts @@ -4,19 +4,16 @@ import { } from '../../shared/command-token-scanner' import type { TuiAgent } from '../../shared/tui-agent' import { getManagedDataAccountService } from './service' -import { - captureManagedDataAccountOriginalEnvironment, - MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS, - restoreManagedDataAccountEnvironment -} from '../../shared/managed-data-account-environment' +import { MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS } from '../../shared/managed-data-account-environment' export function applyManagedDataAccountEnvironment( environment: Record, options: { launchAgent?: TuiAgent; launchCommand?: string; isWsl?: boolean } ): void { - restoreManagedDataAccountEnvironment(environment) + const service = getManagedDataAccountService() + service.restoreOriginalEnvironment(environment) const inherited = { ...process.env } - restoreManagedDataAccountEnvironment(inherited) + service.restoreOriginalEnvironment(inherited) for (const key of MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS) { const value = inherited[key] if (environment[key] === undefined && value !== undefined) { @@ -37,11 +34,11 @@ export function applyManagedDataAccountEnvironment( if (!provider) { return } - const selected = getManagedDataAccountService().launchEnvironment(provider) + const selected = service.launchEnvironment(provider) if (!selected.XDG_DATA_HOME) { return } - captureManagedDataAccountOriginalEnvironment(environment) + service.captureOriginalEnvironment(environment, selected) Object.assign(environment, selected) environment.ORCA_DATA_ACCOUNT_DATA_HOME = selected.XDG_DATA_HOME environment.ORCA_DATA_ACCOUNT_STATE_HOME = selected.XDG_STATE_HOME diff --git a/src/main/managed-data-accounts/original-environment.test.ts b/src/main/managed-data-accounts/original-environment.test.ts new file mode 100644 index 00000000000..dbe1ed1ad94 --- /dev/null +++ b/src/main/managed-data-accounts/original-environment.test.ts @@ -0,0 +1,231 @@ +import { createHash } from 'node:crypto' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { describe, expect, it } from 'vitest' +import { ManagedDataAccountService, getManagedDataAccountService } from './service' +import { getAppEnvironment, setAppEnvironment } from '../../shared/app-environment' +import { restoreManagedDataAccountEnvironment } from '../../shared/managed-data-account-environment' + +const selected = { + XDG_DATA_HOME: join(tmpdir(), 'managed-original', 'data'), + XDG_STATE_HOME: join(tmpdir(), 'managed-original', 'state'), + OPENCODE_AUTH_CONTENT: '', + OPENCODE_DB: 'opencode.db' +} + +function hash(value: string | undefined): string | undefined { + return value === undefined ? undefined : createHash('sha256').update(value).digest('hex') +} + +function managedContext( + service: ManagedDataAccountService, + inline: string, + profile = selected +): Record { + const env: Record = { + XDG_DATA_HOME: join(tmpdir(), 'system-original', 'data'), + OPENCODE_AUTH_CONTENT: inline, + OPENCODE_DB: 'system.db' + } + service.captureOriginalEnvironment(env, profile) + return { + ...env, + ...profile, + ORCA_DATA_ACCOUNT_DATA_HOME: profile.XDG_DATA_HOME, + ORCA_DATA_ACCOUNT_STATE_HOME: profile.XDG_STATE_HOME, + ORCA_DATA_ACCOUNT_PROVIDER: 'opencode' + } +} + +describe('host-private managed account originals', () => { + it('restores multiple copied baselines without exposing either in a marker', () => { + const service = new ManagedDataAccountService('private-original-root') + const first = managedContext(service, 'first-inline-baseline') + const second = managedContext(service, 'second-inline-baseline') + for (const [env, inline] of [ + [first, 'first-inline-baseline'], + [second, 'second-inline-baseline'] + ] as const) { + expect(Object.values(env).some((value) => value.includes(inline))).toBe(false) + const copy = { ...env } + service.restoreOriginalEnvironment(copy) + expect(hash(copy.OPENCODE_AUTH_CONTENT) === hash(inline)).toBe(true) + expect(copy.XDG_DATA_HOME).toBe(join(tmpdir(), 'system-original', 'data')) + expect(copy.XDG_STATE_HOME).toBeUndefined() + expect(copy.OPENCODE_DB).toBe('system.db') + expect(copy.ORCA_DATA_ACCOUNT_ORIGINAL_ENV).toBeUndefined() + } + }) + + it.each([ + { XDG_DATA_HOME: join(tmpdir(), 'independent-data') }, + { XDG_STATE_HOME: join(tmpdir(), 'independent-state') }, + { ORCA_DATA_ACCOUNT_DATA_HOME: join(tmpdir(), 'foreign-data') }, + { ORCA_DATA_ACCOUNT_STATE_HOME: join(tmpdir(), 'foreign-state') }, + { ORCA_DATA_ACCOUNT_PROVIDER: 'devin' }, + { OPENCODE_DB: 'independent.db' }, + { OPENCODE_AUTH_CONTENT: 'independent-inline' } + ])('requires the recorded selection, beyond a caller-supplied reference: %o', (override) => { + const service = new ManagedDataAccountService('private-original-root') + const env = { ...managedContext(service, 'private-baseline'), ...override } + service.restoreOriginalEnvironment(env) + expect(Object.values(env).some((value) => value.includes('private-baseline'))).toBe(false) + for (const [key, value] of Object.entries(override)) { + if (!key.startsWith('ORCA_DATA_ACCOUNT_')) { + expect(env[key]).toBe(value) + } + } + }) + + it('does not resolve a reference attached to a different otherwise valid profile', () => { + const service = new ManagedDataAccountService('private-original-root') + const captured = managedContext(service, 'private-baseline') + const foreign = managedContext(service, 'other-baseline', { + ...selected, + XDG_DATA_HOME: join(tmpdir(), 'different-profile', 'data'), + XDG_STATE_HOME: join(tmpdir(), 'different-profile', 'state') + }) + foreign.ORCA_DATA_ACCOUNT_ORIGINAL_ENV = captured.ORCA_DATA_ACCOUNT_ORIGINAL_ENV + service.restoreOriginalEnvironment(foreign) + expect(foreign.OPENCODE_AUTH_CONTENT).toBeUndefined() + }) + + it('does not resolve private references after service restart or explicit disposal', () => { + const service = new ManagedDataAccountService('private-original-root') + const captured = managedContext(service, 'private-baseline') + const restarted = new ManagedDataAccountService('private-original-root') + const foreign = { ...captured } + restarted.restoreOriginalEnvironment(foreign) + expect(foreign.OPENCODE_AUTH_CONTENT).toBeUndefined() + service.clearInlineAuthBaselines() + service.restoreOriginalEnvironment(captured) + expect(captured.OPENCODE_AUTH_CONTENT).toBeUndefined() + }) + + it('disposes sensitive originals when the host userData root changes or shuts down', () => { + const original = getAppEnvironment() + let root = join(tmpdir(), 'original-host-one') + let shutdown: (() => void) | undefined + try { + setAppEnvironment({ + ...original, + onWillQuit: (handler) => { + shutdown = handler + }, + getPath: (name) => (name === 'userData' ? root : original.getPath(name)) + }) + const first = getManagedDataAccountService() + const captured = managedContext(first, 'private-baseline') + root = join(tmpdir(), 'original-host-two') + const second = getManagedDataAccountService() + const copy = { ...captured } + second.restoreOriginalEnvironment(copy) + first.restoreOriginalEnvironment(captured) + expect(copy.OPENCODE_AUTH_CONTENT).toBeUndefined() + expect(captured.OPENCODE_AUTH_CONTENT).toBeUndefined() + const current = managedContext(second, 'current-baseline') + expect(shutdown).toBeDefined() + shutdown?.() + second.restoreOriginalEnvironment(current) + expect(current.OPENCODE_AUTH_CONTENT).toBeUndefined() + } finally { + setAppEnvironment(original) + } + }) + + it('deduplicates originals and refuses the 65th distinct value before changing its marker', () => { + const service = new ManagedDataAccountService('private-original-root') + const first = managedContext(service, 'baseline-0') + for (let i = 0; i < 100; i++) { + expect(managedContext(service, 'baseline-0').ORCA_DATA_ACCOUNT_ORIGINAL_ENV).toBe( + first.ORCA_DATA_ACCOUNT_ORIGINAL_ENV + ) + } + for (let i = 1; i < 64; i++) { + managedContext(service, `baseline-${i}`) + } + const overflow = { OPENCODE_AUTH_CONTENT: 'overflow-baseline' } + const before = hash(JSON.stringify(overflow)) + expect(() => service.captureOriginalEnvironment(overflow, selected)).toThrow('baseline limit') + expect(hash(JSON.stringify(overflow)) === before).toBe(true) + service.restoreOriginalEnvironment(first) + expect(hash(first.OPENCODE_AUTH_CONTENT) === hash('baseline-0')).toBe(true) + }) + + it('bounds both UTF-8 bytes and selection bindings without evicting originals', () => { + const service = new ManagedDataAccountService('private-original-root') + const oversized = { OPENCODE_AUTH_CONTENT: '€'.repeat(22_000) } + expect(() => service.captureOriginalEnvironment(oversized, selected)).toThrow('launch limit') + expect(Object.keys(oversized)).toEqual(['OPENCODE_AUTH_CONTENT']) + const first = managedContext(service, 'same-baseline') + for (let i = 1; i < 64; i++) { + managedContext(service, 'same-baseline', { + ...selected, + XDG_DATA_HOME: join(tmpdir(), `profile-${i}`, 'data') + }) + } + expect(() => + managedContext(service, 'same-baseline', { + ...selected, + XDG_DATA_HOME: join(tmpdir(), 'overflow', 'data') + }) + ).toThrow('context limit') + service.restoreOriginalEnvironment(first) + expect(hash(first.OPENCODE_AUTH_CONTENT) === hash('same-baseline')).toBe(true) + }) +}) + +describe('original marker compatibility', () => { + it.each(['{', '[]', '{}', '{"inlineAuthReference":"invalid"}'])( + 'clears only proven overrides for malformed %s', + (marker) => { + const env = managedContext( + new ManagedDataAccountService('private-original-root'), + 'private-baseline' + ) + env.ORCA_DATA_ACCOUNT_ORIGINAL_ENV = marker + env.XDG_STATE_HOME = join(tmpdir(), 'independent-state') + restoreManagedDataAccountEnvironment(env) + expect(env.XDG_DATA_HOME).toBeUndefined() + expect(env.XDG_STATE_HOME).toBe(join(tmpdir(), 'independent-state')) + expect(env.OPENCODE_DB).toBe('opencode.db') + expect(env.ORCA_DATA_ACCOUNT_ORIGINAL_ENV).toBeUndefined() + } + ) + + it('restores a recognized old plaintext snapshot while stripping it on a foreign relay', () => { + const env = managedContext( + new ManagedDataAccountService('private-original-root'), + 'private-baseline' + ) + env.ORCA_DATA_ACCOUNT_ORIGINAL_ENV = JSON.stringify({ + XDG_DATA_HOME: join(tmpdir(), 'legacy-system-data'), + XDG_STATE_HOME: null, + OPENCODE_AUTH_CONTENT: 'legacy-inline', + OPENCODE_DB: 'legacy.db' + }) + const relay = { ...env } + restoreManagedDataAccountEnvironment(env) + restoreManagedDataAccountEnvironment(relay, false) + expect(hash(env.OPENCODE_AUTH_CONTENT) === hash('legacy-inline')).toBe(true) + expect(env.OPENCODE_DB).toBe('legacy.db') + expect(relay.XDG_DATA_HOME).toBeUndefined() + expect(relay.OPENCODE_AUTH_CONTENT).toBeUndefined() + expect(relay.ORCA_DATA_ACCOUNT_ORIGINAL_ENV).toBeUndefined() + }) + + it('preserves unknown old-client metadata and explicit System values without owned markers', () => { + const service = new ManagedDataAccountService('private-original-root') + const env = managedContext(service, 'private-baseline') + env.ORCA_DATA_ACCOUNT_PROVIDER = 'future-provider' + env.ORCA_DATA_ACCOUNT_FUTURE_METADATA = 'opaque-metadata' + const before = hash(JSON.stringify(env)) + service.restoreOriginalEnvironment(env) + expect(hash(JSON.stringify(env)) === before).toBe(true) + delete env.ORCA_DATA_ACCOUNT_DATA_HOME + env.OPENCODE_AUTH_CONTENT = 'explicit-system-inline' + service.restoreOriginalEnvironment(env) + expect(hash(env.OPENCODE_AUTH_CONTENT) === hash('explicit-system-inline')).toBe(true) + expect(env.ORCA_DATA_ACCOUNT_FUTURE_METADATA).toBe('opaque-metadata') + }) +}) diff --git a/src/main/managed-data-accounts/service.ts b/src/main/managed-data-accounts/service.ts index 4549795f6eb..5393b55ea0d 100644 --- a/src/main/managed-data-accounts/service.ts +++ b/src/main/managed-data-accounts/service.ts @@ -9,6 +9,15 @@ import type { ManagedDataAccountsState } from '../../shared/managed-account-types' import { captureDataAccountCredentials } from './credential-capture' +import { + captureManagedDataAccountOriginalEnvironment, + restoreManagedDataAccountEnvironment +} from '../../shared/managed-data-account-environment' + +const MAX_INLINE_AUTH_BASELINES = 64 +const MAX_INLINE_AUTH_BYTES = 64 * 1024 + +type InlineAuthBaseline = { value: string; selections: Set } const stateSchema = z.object({ accounts: z @@ -27,6 +36,7 @@ const stateSchema = z.object({ export class ManagedDataAccountService { private pending: Promise = Promise.resolve() private readonly listeners = new Set<() => void>() + private readonly inlineAuthBaselines = new Map() constructor( private readonly root: string, @@ -123,6 +133,61 @@ export class ManagedDataAccountService { return [...selected, ...others].map((account) => this.profileEnvironment(provider, account.id)) } + captureOriginalEnvironment( + environment: Record, + selected: Record + ): void { + const value = environment.OPENCODE_AUTH_CONTENT + let reference: string | undefined + if (value && selected.OPENCODE_AUTH_CONTENT === '') { + if (Buffer.byteLength(value, 'utf8') > MAX_INLINE_AUTH_BYTES) { + throw new Error('Inline authentication exceeds the managed launch limit.') + } + const selection = JSON.stringify([selected.XDG_DATA_HOME, selected.XDG_STATE_HOME]) + const existing = [...this.inlineAuthBaselines].find(([, entry]) => entry.value === value) + if (existing) { + const [id, entry] = existing + if ( + !entry.selections.has(selection) && + entry.selections.size >= MAX_INLINE_AUTH_BASELINES + ) { + throw new Error('Managed inline authentication context limit reached.') + } + entry.selections.add(selection) + reference = id + } else { + if (this.inlineAuthBaselines.size >= MAX_INLINE_AUTH_BASELINES) { + throw new Error('Managed inline authentication baseline limit reached.') + } + reference = randomUUID() + this.inlineAuthBaselines.set(reference, { value, selections: new Set([selection]) }) + } + } + captureManagedDataAccountOriginalEnvironment(environment, reference) + } + + restoreOriginalEnvironment(environment: Record): void { + // A copied reference needs the exact overlay captured by this host service. + const selection = JSON.stringify([ + environment.ORCA_DATA_ACCOUNT_DATA_HOME, + environment.ORCA_DATA_ACCOUNT_STATE_HOME + ]) + const ownsSelection = + environment.ORCA_DATA_ACCOUNT_PROVIDER === 'opencode' && + environment.OPENCODE_AUTH_CONTENT === '' && + environment.OPENCODE_DB === 'opencode.db' && + environment.XDG_DATA_HOME === environment.ORCA_DATA_ACCOUNT_DATA_HOME && + environment.XDG_STATE_HOME === environment.ORCA_DATA_ACCOUNT_STATE_HOME + restoreManagedDataAccountEnvironment(environment, true, (reference) => { + const baseline = this.inlineAuthBaselines.get(reference) + return ownsSelection && baseline?.selections.has(selection) ? baseline.value : undefined + }) + } + + clearInlineAuthBaselines(): void { + this.inlineAuthBaselines.clear() + } + private profileEnvironment( provider: ManagedDataAccountProvider, accountId: string @@ -185,11 +250,20 @@ export class ManagedDataAccountService { } let instance: { root: string; service: ManagedDataAccountService } | undefined +let shutdownHookInstalled = false export function getManagedDataAccountService(): ManagedDataAccountService { const root = resolve(getAppEnvironment().getPath('userData'), 'managed-data-accounts') if (instance?.root !== root) { + instance?.service.clearInlineAuthBaselines() instance = { root, service: new ManagedDataAccountService(root) } } + if (!shutdownHookInstalled) { + getAppEnvironment().onWillQuit(() => { + instance?.service.clearInlineAuthBaselines() + instance = undefined + }) + shutdownHookInstalled = true + } return instance.service } diff --git a/src/shared/managed-data-account-environment.ts b/src/shared/managed-data-account-environment.ts index 8bfeca0af8d..313324533a1 100644 --- a/src/shared/managed-data-account-environment.ts +++ b/src/shared/managed-data-account-environment.ts @@ -4,7 +4,8 @@ const originalEnvironment = z.object({ XDG_DATA_HOME: z.string().nullable(), XDG_STATE_HOME: z.string().nullable(), OPENCODE_AUTH_CONTENT: z.string().nullable(), - OPENCODE_DB: z.string().nullable() + OPENCODE_DB: z.string().nullable(), + inlineAuthReference: z.uuid().optional() }) const ORIGINAL_ENV = 'ORCA_DATA_ACCOUNT_ORIGINAL_ENV' export const MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS = [ @@ -14,32 +15,29 @@ export const MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS = [ 'OPENCODE_DB' ] as const -export function getInheritedManagedDataAccountEnvKeysToDelete( - environment: Record | undefined -): string[] { - return [ - ...MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS, - 'ORCA_DATA_ACCOUNT_DATA_HOME', - 'ORCA_DATA_ACCOUNT_STATE_HOME', - 'ORCA_DATA_ACCOUNT_PROVIDER', - ORIGINAL_ENV - ].filter((key) => environment?.[key] === undefined) -} - export function captureManagedDataAccountOriginalEnvironment( - environment: Record + environment: Record, + inlineAuthReference?: string ): void { - environment[ORIGINAL_ENV] = JSON.stringify( - Object.fromEntries( + environment[ORIGINAL_ENV] = JSON.stringify({ + ...Object.fromEntries( MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS.map((key) => [key, environment[key] ?? null]) - ) - ) + ), + OPENCODE_AUTH_CONTENT: environment.OPENCODE_AUTH_CONTENT === '' ? '' : null, + ...(inlineAuthReference ? { inlineAuthReference } : {}) + }) } export function restoreManagedDataAccountEnvironment( environment: Record, - restoreOriginal = true + restoreOriginal = true, + resolveInlineAuth?: (reference: string) => string | undefined ): void { + const provider = environment.ORCA_DATA_ACCOUNT_PROVIDER + const dataHome = environment.ORCA_DATA_ACCOUNT_DATA_HOME + if (!dataHome || (provider !== undefined && provider !== 'opencode' && provider !== 'devin')) { + return + } let original: z.infer | undefined try { const parsed = originalEnvironment.safeParse(JSON.parse(environment[ORIGINAL_ENV] ?? 'null')) @@ -49,6 +47,15 @@ export function restoreManagedDataAccountEnvironment( } catch { // Older panes have no baseline snapshot; strip only their owned overrides. } + const ownsOpenCode = + provider === 'opencode' && + environment.XDG_DATA_HOME === dataHome && + environment.ORCA_DATA_ACCOUNT_STATE_HOME !== undefined && + environment.XDG_STATE_HOME === environment.ORCA_DATA_ACCOUNT_STATE_HOME + const inlineAuth = + ownsOpenCode && environment.OPENCODE_AUTH_CONTENT === '' && original?.inlineAuthReference + ? resolveInlineAuth?.(original.inlineAuthReference) + : original?.OPENCODE_AUTH_CONTENT function restore( key: (typeof MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS)[number], ownedValue: string | undefined @@ -56,20 +63,18 @@ export function restoreManagedDataAccountEnvironment( if (ownedValue === undefined || environment[key] !== ownedValue) { return } - const value = original?.[key] + const value = key === 'OPENCODE_AUTH_CONTENT' ? inlineAuth : original?.[key] if (typeof value === 'string') { environment[key] = value } else { delete environment[key] } } - if (environment.ORCA_DATA_ACCOUNT_DATA_HOME) { - restore('XDG_DATA_HOME', environment.ORCA_DATA_ACCOUNT_DATA_HOME) - restore('XDG_STATE_HOME', environment.ORCA_DATA_ACCOUNT_STATE_HOME) - if (environment.ORCA_DATA_ACCOUNT_PROVIDER === 'opencode') { - restore('OPENCODE_AUTH_CONTENT', '') - restore('OPENCODE_DB', 'opencode.db') - } + restore('XDG_DATA_HOME', dataHome) + restore('XDG_STATE_HOME', environment.ORCA_DATA_ACCOUNT_STATE_HOME) + if (ownsOpenCode) { + restore('OPENCODE_AUTH_CONTENT', '') + restore('OPENCODE_DB', 'opencode.db') } delete environment.ORCA_DATA_ACCOUNT_DATA_HOME delete environment.ORCA_DATA_ACCOUNT_STATE_HOME From c49896e1d4cfd50893e92b196f3f31814bed59cb Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 06:35:10 -0700 Subject: [PATCH 11/15] Align account enrollment help with accepted providers and flags --- src/cli/handlers/account.test.ts | 4 +++- src/cli/handlers/account.ts | 4 ++-- src/cli/specs/account.test.ts | 1 + src/cli/specs/account.ts | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/cli/handlers/account.test.ts b/src/cli/handlers/account.test.ts index 9cecb6f12e4..8b2fe9d5ae7 100644 --- a/src/cli/handlers/account.test.ts +++ b/src/cli/handlers/account.test.ts @@ -715,7 +715,9 @@ describe('account CLI handlers', () => { // default would run a full OAuth login for the wrong provider. await expect( ACCOUNT_HANDLERS['account add']({ ...context('claude'), flags: new Map([['agent', true]]) }) - ).rejects.toThrow('Missing a value for --agent') + ).rejects.toThrow( + 'Missing a value for --agent. Use `--agent claude`, `--agent codex`, `--agent opencode`, or `--agent devin`.' + ) expect(spawnMock).not.toHaveBeenCalled() }) diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index 2fdc394b87e..19d7cac1b49 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -275,7 +275,7 @@ async function assertAccountImportSupported({ client }: HandlerContext): Promise } } -/** CLI handlers for `orca account add [--agent claude|codex]` and `orca account list`. */ +/** CLI handlers for managed account enrollment and listing. */ export const ACCOUNT_HANDLERS: Record = { 'account add': async (ctx) => { const agentFlag = ctx.flags.get('agent') @@ -284,7 +284,7 @@ export const ACCOUNT_HANDLERS: Record = { if (agentFlag !== undefined && typeof agentFlag !== 'string') { throw new RuntimeClientError( 'invalid_argument', - 'Missing a value for --agent. Use `--agent claude` or `--agent codex`.' + 'Missing a value for --agent. Use `--agent claude`, `--agent codex`, `--agent opencode`, or `--agent devin`.' ) } const agent = agentFlag ?? 'claude' diff --git a/src/cli/specs/account.test.ts b/src/cli/specs/account.test.ts index 91dc2cf48d5..1ab5d61b116 100644 --- a/src/cli/specs/account.test.ts +++ b/src/cli/specs/account.test.ts @@ -35,6 +35,7 @@ describe('account command specs', () => { expect(help).toContain('Account provider: claude, codex, opencode, or devin (default claude)') expect(help).not.toContain('TUI agent') + expect(spec('account add').usage).toContain('[--integration ]') }) it('aligns the --agent description with the global flag descriptions', () => { diff --git a/src/cli/specs/account.ts b/src/cli/specs/account.ts index 0f1cc0306ec..0411bdbd1d0 100644 --- a/src/cli/specs/account.ts +++ b/src/cli/specs/account.ts @@ -9,7 +9,8 @@ export const ACCOUNT_COMMAND_SPECS: CommandSpec[] = [ { path: ['account', 'add'], summary: 'Add a managed agent account by signing in on this Orca host', - usage: 'orca account add [--agent claude|codex|opencode|devin] [--label ] [--json]', + usage: + 'orca account add [--agent claude|codex|opencode|devin] [--label ] [--integration ] [--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.', From ed29c539030352662603c50c8b42b1405b8502c4 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 06:36:13 -0700 Subject: [PATCH 12/15] Show the active System account in managed profile lists --- src/cli/handlers/account-list-format.ts | 6 ++++- .../handlers/data-account-commands.test.ts | 26 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/cli/handlers/account-list-format.ts b/src/cli/handlers/account-list-format.ts index 2b76d7a0f1e..66da6d4b1b7 100644 --- a/src/cli/handlers/account-list-format.ts +++ b/src/cli/handlers/account-list-format.ts @@ -12,7 +12,11 @@ type AccountsBlock = { } export function formatDataAccounts(label: string, state: ManagedDataAccountsState): string { - return `Managed ${label} accounts (${state.accounts.length}):\n${state.accounts + const system = ` system System default${state.activeAccountId === null ? ' (active)' : ''}` + if (state.accounts.length === 0) { + return `No managed ${label} accounts.\n${system}` + } + return `Managed ${label} accounts (${state.accounts.length}):\n${system}\n${state.accounts .map( (account) => ` ${account.id} ${account.label}${account.id === state.activeAccountId ? ' (active)' : ''}` diff --git a/src/cli/handlers/data-account-commands.test.ts b/src/cli/handlers/data-account-commands.test.ts index a440b9ccf88..6dd4b2e2362 100644 --- a/src/cli/handlers/data-account-commands.test.ts +++ b/src/cli/handlers/data-account-commands.test.ts @@ -4,7 +4,7 @@ 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' +import { addDataAccount, listDataAccounts } from './data-account-commands' const client = new RuntimeClient(join(tmpdir(), 'orca-login-test'), 1000, null, null) const context = { @@ -18,6 +18,30 @@ const context = { afterEach(() => vi.restoreAllMocks()) describe('managed data account enrollment', () => { + it.each(['opencode', 'devin'])( + 'shows the active System default for an empty %s roster', + async (provider) => { + vi.spyOn(client, 'call') + .mockResolvedValueOnce({ + id: 'test', + ok: true, + result: { capabilities: [DATA_ACCOUNT_RUNTIME_CAPABILITY] }, + _meta: { runtimeId: 'test' } + }) + .mockResolvedValueOnce({ + id: 'test', + ok: true, + result: { [provider]: { accounts: [], activeAccountId: null } }, + _meta: { runtimeId: 'test' } + }) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + await listDataAccounts({ ...context, json: false }, provider) + expect(log).toHaveBeenCalledWith( + `No managed ${provider} accounts.\n system System default (active)` + ) + } + ) + it('refuses an old host before starting login', async () => { vi.spyOn(client, 'call').mockResolvedValue({ id: 'test', From 4d92716a69c9ac2b95d46397403ec57326c73dca Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 06:36:32 -0700 Subject: [PATCH 13/15] Document the validated Linux managed account scope --- docs/reference/managed-data-accounts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/managed-data-accounts.md b/docs/reference/managed-data-accounts.md index a8db7c6762f..7967ab231e5 100644 --- a/docs/reference/managed-data-accounts.md +++ b/docs/reference/managed-data-accounts.md @@ -20,4 +20,4 @@ Selection affects newly launched explicit OpenCode/Devin commands and agent laun For SSH, enroll by running the command on a headless Orca runtime on the remote machine. The remote runtime owns its profiles and selection; a desktop client's credential paths never cross SSH. Direct SSH relay launches and Windows-hosted WSL panes do not consume the desktop host's profiles. Run a headless runtime inside that execution environment instead. Folder workspaces use the same host account store as git worktrees. Older Orca hosts reject new operations before login through capability negotiation. -Validation currently covers OpenCode 2.0.16 on macOS, real isolated login and selected terminal authentication, Devin 3000.10.31 saved-login recognition, and the Node headless runtime. Fresh Devin manual-token enrollment, a physical SSH host, Linux, and Windows still require verification; these are not claimed as tested. +Validation covers OpenCode 2.0.16 on macOS and Linux arm64, including isolated official enrollment, selected and System background-terminal credential checks, reselection, and profile deletion. Linux checks used the Node headless runtime in an Ubuntu 24.04 container. Devin 3000.10.31 saved-login recognition was checked on macOS. Fresh Devin manual-token enrollment, a physical SSH host, Linux desktop UI, Windows, and Windows-hosted WSL still require verification. From fa9f6a0a57f202d0c11d8786db3807a941d52c69 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 18:05:26 -0700 Subject: [PATCH 14/15] Fix managed account removal, credential audits, and runtime bundling --- .../electron-vite-output-contract.test.ts | 3 + .../managed-data-account-runtime.test.ts | 106 ++++++++++++++++++ electron.vite.config.ts | 1 + .../credential-capture.ts | 27 ++++- .../managed-data-accounts/service.test.ts | 95 +++++++++++++++- src/main/managed-data-accounts/service.ts | 56 +++++++-- 6 files changed, 272 insertions(+), 16 deletions(-) create mode 100644 config/scripts/managed-data-account-runtime.test.ts diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index da6d45c8a23..fd45c42705f 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -144,8 +144,11 @@ describe('Electron Vite output contract', () => { expect(external('@xterm/addon-serialize', undefined, false)).toBe(false) expect(external('tldts', undefined, false)).toBe(false) expect(external('zod', undefined, false)).toBe(false) + expect(external('smol-toml', undefined, false)).toBe(false) + expect(external('smol-toml/package.json', undefined, false)).toBe(false) expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('tldts') expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod') + expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('smol-toml') }) it('bundles validation dependencies used by the sandboxed preload', () => { diff --git a/config/scripts/managed-data-account-runtime.test.ts b/config/scripts/managed-data-account-runtime.test.ts new file mode 100644 index 00000000000..452629732ac --- /dev/null +++ b/config/scripts/managed-data-account-runtime.test.ts @@ -0,0 +1,106 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { resolveConfig } from 'electron-vite' +import { build } from 'vite' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { runProcess } from '../../src/shared/child-process/run-process' + +const projectDir = resolve(import.meta.dirname, '../..') +const require = createRequire(import.meta.url) +let outputDir: string + +beforeAll(async () => { + outputDir = mkdtempSync(join(tmpdir(), 'orca-account-runtime-')) + const resolved = await resolveConfig( + { configFile: join(projectDir, 'electron.vite.config.ts') }, + 'build', + 'production' + ) + const main = resolved.config?.main + if (!main?.build) { + throw new Error('Expected main-process build config') + } + await build({ + ...main, + logLevel: 'silent', + build: { + ...main.build, + outDir: join(outputDir, 'bundle'), + sourcemap: false, + rollupOptions: { + ...main.build.rollupOptions, + input: join(projectDir, 'src/main/managed-data-accounts/credential-capture.ts'), + output: { format: 'cjs', entryFileNames: 'credential-capture.cjs' } + } + } + }) + mkdirSync(join(outputDir, 'source', 'devin'), { recursive: true }) + writeFileSync( + join(outputDir, 'source', 'devin', 'credentials.toml'), + 'windsurf_api_key = "offline-account-runtime-fixture"\n' + ) +}) + +afterAll(() => { + if (outputDir) { + rmSync(outputDir, { recursive: true, force: true }) + } +}) + +describe('managed account credentials in the production main bundle', () => { + it.each([ + { name: 'Node', program: process.execPath }, + { name: 'Electron', program: require('electron') } + ])( + 'captures Devin credentials under $name outside the dependency install', + async ({ name, program }) => { + const environment: Record = { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + ELECTRON_RUN_AS_NODE: '1' + } + for (const key of [ + 'HOME', + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'XDG_STATE_HOME', + 'XDG_CACHE_HOME' + ]) { + const directory = join(outputDir, name, key) + mkdirSync(directory, { recursive: true }) + environment[key] = directory + } + const script = ` + const assert = require('node:assert/strict') + const { captureDataAccountCredentials } = require(process.argv[1]) + captureDataAccountCredentials('devin', process.argv[2], process.argv[3]) + .then((integrations) => { + assert.deepEqual(integrations, ['devin']) + console.log('Private credentials captured') + }).catch((error) => { console.error(error); process.exitCode = 1 }) + ` + const destination = join(outputDir, name, 'captured') + const result = await runProcess({ + program, + args: [ + '-e', + script, + join(outputDir, 'bundle', 'credential-capture.cjs'), + join(outputDir, 'source'), + destination + ], + cwd: outputDir, + env: environment, + timeoutMs: 20000 + }) + expect(result.code, result.stderr).toBe(0) + expect(result.timedOut).toBe(false) + expect(result.stdout.trim()).toBe('Private credentials captured') + expect(readFileSync(join(destination, 'devin', 'credentials.toml'), 'utf8')).toContain( + 'offline-account-runtime-fixture' + ) + } + ) +}) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d1b4e2d7b97..7e715a06616 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -16,6 +16,7 @@ const BUNDLED_MAIN_DEPENDENCIES = new Set([ '@xterm/headless', '@xterm/addon-serialize', 'tldts', + 'smol-toml', // Why: Windows NSIS deploys app.asar before external resources; bootstrap must // not race the later resources/node_modules copy. 'zod' diff --git a/src/main/managed-data-accounts/credential-capture.ts b/src/main/managed-data-accounts/credential-capture.ts index 22de0bbe2e2..6a2036aceda 100644 --- a/src/main/managed-data-accounts/credential-capture.ts +++ b/src/main/managed-data-accounts/credential-capture.ts @@ -3,6 +3,7 @@ import { join } from 'node:path' import { z } from 'zod' import { parse } from 'smol-toml' import SyncDatabase from '../sqlite/sync-database' +import { tableExists } from '../opencode-usage/schema-helpers' import { writeSecureFile } from '../../shared/secure-file' import type { ManagedDataAccountProvider } from '../../shared/managed-account-types' @@ -58,14 +59,30 @@ export async function captureDataAccountCredentials( }) 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) - ) + const sessionTables = ['session', 'session_v2'].filter((name) => tableExists(database, 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) { + // Deleted sessions can leave orphan content or durable events behind. + const conversationTables = [ + ...sessionTables, + 'message', + 'part', + 'todo', + 'session_message', + 'session_pending', + 'session_inbox', + 'session_input', + 'session_context_epoch', + 'instruction_blob', + 'instruction_entry', + 'instruction_state', + 'event' + ] + for (const table of conversationTables) { + if (!tableExists(database, table)) { + continue + } 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.' diff --git a/src/main/managed-data-accounts/service.test.ts b/src/main/managed-data-accounts/service.test.ts index d9ad4cd2c12..8c247bbd5f1 100644 --- a/src/main/managed-data-accounts/service.test.ts +++ b/src/main/managed-data-accounts/service.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, afterEach, describe, expect, it } from 'vitest' +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' import { existsSync, mkdirSync, @@ -13,6 +13,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import SyncDatabase from '../sqlite/sync-database' +import * as secureFile from '../../shared/secure-file' import { ManagedDataAccountService } from './service' let root: string @@ -26,7 +27,10 @@ beforeEach(() => { 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 })) +afterEach(() => { + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) function openCodeSource(sessionTable = 'session'): void { mkdirSync(join(source, 'opencode'), { recursive: true }) @@ -42,6 +46,51 @@ function openCodeSource(sessionTable = 'session'): void { } describe('managed data accounts', () => { + it.each(['before write', 'after write', 'unrestricted'])( + 'preserves credentials and original metadata when removal persistence fails %s', + async (failure) => { + const before = await service.add('devin', source, 'Work') + const environment = service.launchEnvironment('devin') + const credentialsPath = join(environment.XDG_DATA_HOME, 'devin', 'credentials.toml') + const credentials = readFileSync(credentialsPath) + const metadataPath = join(root, 'managed', 'devin', 'accounts.json') + const metadata = readFileSync(metadataPath) + const changed = vi.fn() + service.onChanged(changed) + const write = secureFile.writeSecureFile + const failingWrite = vi.spyOn(secureFile, 'writeSecureFile').mockImplementation((...args) => { + if (args[0] !== metadataPath) { + return write(...args) + } + if (failure === 'before write') { + throw new Error('metadata write failed') + } + write(...args) + if (failure === 'unrestricted') { + return false + } + throw new Error('metadata write failed') + }) + + await expect(service.remove('devin', before.accounts[0].id)).rejects.toThrow( + failure === 'unrestricted' ? 'metadata permissions' : 'metadata write failed' + ) + expect(readFileSync(credentialsPath)).toEqual(credentials) + expect(readFileSync(metadataPath)).toEqual(metadata) + expect(service.list('devin')).toEqual(before) + expect(service.launchEnvironment('devin')).toEqual(environment) + expect(changed).not.toHaveBeenCalled() + expect(readdirSync(join(root, 'managed', 'devin')).sort()).toEqual( + [before.accounts[0].id, 'accounts.json'].sort() + ) + + failingWrite.mockRestore() + await service.remove('devin', before.accounts[0].id) + expect(changed).toHaveBeenCalledTimes(1) + expect(service.list('devin')).toEqual({ accounts: [], activeAccountId: null }) + } + ) + it('retains selected account metadata after locked cleanup and permits retry', async () => { let locked = true service = new ManagedDataAccountService(join(root, 'managed'), (directory) => { @@ -51,12 +100,23 @@ describe('managed data accounts', () => { rmSync(directory, { recursive: true, force: true }) }) const before = await service.add('devin', source, 'Work') + const environment = service.launchEnvironment('devin') + const metadataPath = join(root, 'managed', 'devin', 'accounts.json') + const metadata = readFileSync(metadataPath) + const changed = vi.fn() + service.onChanged(changed) await expect(service.remove('devin', before.accounts[0].id)).rejects.toThrow('file locked') expect(service.list('devin')).toEqual(before) - expect(service.launchEnvironment('devin').XDG_DATA_HOME).toBeTruthy() + expect(readFileSync(metadataPath)).toEqual(metadata) + expect(service.launchEnvironment('devin')).toEqual(environment) + expect( + readFileSync(join(environment.XDG_DATA_HOME, 'devin', 'credentials.toml'), 'utf8') + ).toContain('test-only-key') + expect(changed).not.toHaveBeenCalled() locked = false await service.remove('devin', before.accounts[0].id) expect(service.list('devin')).toEqual({ accounts: [], activeAccountId: null }) + expect(changed).toHaveBeenCalledTimes(1) }) it('registers private Devin credentials, exposes summaries, and removes only its profile', async () => { @@ -117,6 +177,35 @@ describe('managed data accounts', () => { expect(readdirSync(join(root, 'managed', 'opencode'))).toEqual([]) }) + it.each([ + 'message', + 'part', + 'todo', + 'session_message', + 'session_pending', + 'session_inbox', + 'session_input', + 'session_context_epoch', + 'instruction_blob', + 'instruction_entry', + 'instruction_state', + 'event' + ])('rejects synthetic orphan %s rows even with empty session containers', async (table) => { + openCodeSource('session_v2') + const databasePath = join(source, 'opencode', 'opencode.db') + const db = new SyncDatabase(databasePath) + // Synthetic orphans exercise damaged/FK-off files, not normal CLI writes. + db.exec(`CREATE TABLE ${table} (data TEXT)`) + db.prepare(`INSERT INTO ${table} VALUES (?)`).run('private-conversation-content') + db.close() + const original = readFileSync(databasePath) + + await expect(service.add('opencode', source, 'Work')).rejects.toThrow('conversation databases') + expect(service.list('opencode')).toEqual({ accounts: [], activeAccountId: null }) + expect(readdirSync(join(root, 'managed', 'opencode'))).toEqual([]) + expect(readFileSync(databasePath)).toEqual(original) + }) + 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']) diff --git a/src/main/managed-data-accounts/service.ts b/src/main/managed-data-accounts/service.ts index 5393b55ea0d..4b51caa2742 100644 --- a/src/main/managed-data-accounts/service.ts +++ b/src/main/managed-data-accounts/service.ts @@ -1,5 +1,13 @@ import { randomUUID } from 'node:crypto' -import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs' +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync +} from 'node:fs' import { join, resolve, sep } from 'node:path' import { z } from 'zod' import { getAppEnvironment } from '../../shared/app-environment' @@ -110,11 +118,31 @@ export class ManagedDataAccountService { if (existsSync(directory)) { this.assertOwned(directory) } - this.removeDirectory(directory) - return this.persist(provider, { - accounts: state.accounts.filter((account) => account.id !== accountId), - activeAccountId: state.activeAccountId === accountId ? null : state.activeAccountId - }) + const metadataPath = join(this.root, provider, 'accounts.json') + const rollbackPath = `${metadataPath}.${randomUUID()}.rollback` + if (!writeSecureFile(rollbackPath, readFileSync(metadataPath, 'utf8'), { durable: true })) { + rmSync(rollbackPath, { force: true }) + throw new Error('Could not restrict account metadata backup permissions.') + } + let next: ManagedDataAccountsState + try { + // Keep the original metadata until persistence and cleanup both succeed. + next = this.writeState(provider, { + accounts: state.accounts.filter((account) => account.id !== accountId), + activeAccountId: state.activeAccountId === accountId ? null : state.activeAccountId + }) + this.removeDirectory(directory) + } catch (error) { + renameSync(rollbackPath, metadataPath) + throw error + } + try { + rmSync(rollbackPath, { force: true }) + } catch { + console.warn('[managed-data-accounts] Could not remove account metadata backup.') + } + this.notifyChanged() + return next }) } @@ -217,19 +245,31 @@ export class ManagedDataAccountService { private persist( provider: ManagedDataAccountProvider, state: ManagedDataAccountsState + ): ManagedDataAccountsState { + const checked = this.writeState(provider, state) + this.notifyChanged() + return checked + } + + private writeState( + 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))) { + if (!writeSecureFile(path, JSON.stringify(checked), { durable: true })) { throw new Error('Could not restrict account metadata permissions.') } + return checked + } + + private notifyChanged(): void { for (const listener of this.listeners) { listener() } - return checked } private assertOwned(path: string): void { From 3c62b4a0ba13120c215cbb0e8c86e6891f85f63f Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 21:03:01 -0700 Subject: [PATCH 15/15] Quarantine removed accounts and audit captured credential snapshots --- .../credential-capture.ts | 126 ++++---- .../managed-data-accounts/profile-removal.ts | 138 +++++++++ .../managed-data-accounts/service.test.ts | 280 +++++++++++++++++- src/main/managed-data-accounts/service.ts | 79 ++--- 4 files changed, 516 insertions(+), 107 deletions(-) create mode 100644 src/main/managed-data-accounts/profile-removal.ts diff --git a/src/main/managed-data-accounts/credential-capture.ts b/src/main/managed-data-accounts/credential-capture.ts index 6a2036aceda..dd077d4aa05 100644 --- a/src/main/managed-data-accounts/credential-capture.ts +++ b/src/main/managed-data-accounts/credential-capture.ts @@ -1,4 +1,4 @@ -import { lstatSync, readFileSync } from 'node:fs' +import { lstatSync, readFileSync, rmSync } from 'node:fs' import { join } from 'node:path' import { z } from 'zod' import { parse } from 'smol-toml' @@ -26,6 +26,59 @@ function requireRegularFile(path: string): void { } } +function auditOpenCodeCredentials(database: SyncDatabase): string[] { + database.pragma('query_only = ON') + const sessionTables = ['session', 'session_v2'].filter((name) => tableExists(database, name)) + if (sessionTables.length === 0) { + throw new Error('Unsupported OpenCode credential database.') + } + // Deleted sessions can leave orphan content or durable events behind. + const conversationTables = [ + ...sessionTables, + 'message', + 'part', + 'todo', + 'session_message', + 'session_pending', + 'session_inbox', + 'session_input', + 'session_context_epoch', + 'instruction_blob', + 'instruction_entry', + 'instruction_state', + 'event' + ] + for (const table of conversationTables) { + if (!tableExists(database, table)) { + continue + } + 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.') + } + return 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 + }) +} + export async function captureDataAccountCredentials( provider: ManagedDataAccountProvider, sourceDataHome: string, @@ -57,63 +110,30 @@ export async function captureDataAccountCredentials( fileMustExist: true, timeout: 1500 }) + const destination = join(destinationDataHome, 'opencode', 'opencode.db') + let snapshotCreated = false try { - database.pragma('query_only = ON') - const sessionTables = ['session', 'session_v2'].filter((name) => tableExists(database, name)) - if (sessionTables.length === 0) { - throw new Error('Unsupported OpenCode credential database.') - } - // Deleted sessions can leave orphan content or durable events behind. - const conversationTables = [ - ...sessionTables, - 'message', - 'part', - 'todo', - 'session_message', - 'session_pending', - 'session_inbox', - 'session_input', - 'session_context_epoch', - 'instruction_blob', - 'instruction_entry', - 'instruction_state', - 'event' - ] - for (const table of conversationTables) { - if (!tableExists(database, table)) { - continue - } - 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') + auditOpenCodeCredentials(database) + snapshotCreated = true if (!writeSecureFile(destination, '')) { throw new Error('Could not restrict OpenCode credential file permissions.') } await database.backup(destination) - return integrations + // The source can change while SQLite copies; only the completed private snapshot is publishable. + requireRegularFile(destination) + const snapshot = new SyncDatabase(destination, { readonly: true, fileMustExist: true }) + try { + return auditOpenCodeCredentials(snapshot) + } finally { + snapshot.close() + } + } catch (error) { + if (snapshotCreated) { + for (const path of [destination, `${destination}-wal`, `${destination}-shm`]) { + rmSync(path, { force: true }) + } + } + throw error } finally { database.close() } diff --git a/src/main/managed-data-accounts/profile-removal.ts b/src/main/managed-data-accounts/profile-removal.ts new file mode 100644 index 00000000000..25f678317e8 --- /dev/null +++ b/src/main/managed-data-accounts/profile-removal.ts @@ -0,0 +1,138 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs' +import { readdir } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { z } from 'zod' +import { removeHostTree } from '../host-tree-removal' +import type { + ManagedDataAccountProvider, + ManagedDataAccountsState +} from '../../shared/managed-account-types' +import { writeSecureFile } from '../../shared/secure-file' + +export class ManagedDataAccountProfileRemoval { + constructor( + private readonly root: string, + private readonly assertOwned: (path: string) => void, + private readonly removeDirectory: (directory: string) => void | Promise = removeHostTree + ) {} + + async remove( + provider: ManagedDataAccountProvider, + accountId: string, + state: ManagedDataAccountsState, + publish: (state: ManagedDataAccountsState) => ManagedDataAccountsState, + changed: () => void + ): Promise { + if (!z.uuid().safeParse(accountId).success) { + throw new Error('Managed account not found.') + } + const directory = join(this.root, provider, accountId) + const pendingDirectory = join(this.root, provider, '.pending-delete', accountId) + const metadataPath = join(this.root, provider, 'accounts.json') + const rollbackPath = `${metadataPath}.${accountId}.rollback` + if (!state.accounts.some((account) => account.id === accountId)) { + if (existsSync(rollbackPath) && existsSync(directory)) { + this.assertOwned(rollbackPath) + this.quarantine(directory, pendingDirectory) + changed() + } else if (!existsSync(pendingDirectory)) { + throw new Error('Managed account not found.') + } + this.discardBackup(rollbackPath) + await this.cleanup(pendingDirectory) + return state + } + if (existsSync(rollbackPath)) { + this.assertOwned(rollbackPath) + } + if (!writeSecureFile(rollbackPath, readFileSync(metadataPath, 'utf8'), { durable: true })) { + rmSync(rollbackPath, { force: true }) + throw new Error('Could not restrict account metadata backup permissions.') + } + let next: ManagedDataAccountsState + try { + next = publish({ + accounts: state.accounts.filter((account) => account.id !== accountId), + activeAccountId: state.activeAccountId === accountId ? null : state.activeAccountId + }) + this.quarantine(directory, pendingDirectory) + } catch (error) { + try { + renameSync(rollbackPath, metadataPath) + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + 'Account removal failed and its private metadata backup could not be restored; retry removal to recover.', + { cause: error } + ) + } + throw error + } + this.discardBackup(rollbackPath) + changed() + // Cleanup can partially delete a tree, so it must never roll back a committed removal. + await this.cleanup(pendingDirectory) + return next + } + + private quarantine(directory: string, pendingDirectory: string): void { + if (!existsSync(directory)) { + return + } + this.assertOwned(directory) + if (existsSync(pendingDirectory)) { + throw new Error('Account already has a pending removal directory.') + } + const pendingRoot = dirname(pendingDirectory) + mkdirSync(pendingRoot, { recursive: true, mode: 0o700 }) + this.assertOwned(pendingRoot) + renameSync(directory, pendingDirectory) + } + + private discardBackup(rollbackPath: string): void { + try { + rmSync(rollbackPath, { force: true }) + } catch { + console.warn('[managed-data-accounts] Could not remove account metadata backup.') + } + } + + private async cleanup(directory: string): Promise { + if (!existsSync(directory)) { + return + } + try { + this.assertOwned(dirname(directory)) + this.assertOwned(directory) + await this.removeDirectory(directory) + } catch { + console.warn( + '[managed-data-accounts] Account removed; private directory cleanup is deferred.' + ) + } + } + + async retry(provider: ManagedDataAccountProvider, registered: Set): Promise { + const pendingRoot = join(this.root, provider, '.pending-delete') + if (!existsSync(pendingRoot)) { + return + } + try { + this.assertOwned(pendingRoot) + const entries = await readdir(pendingRoot, { withFileTypes: true }) + for (const entry of entries) { + if ( + !entry.isDirectory() || + !z.uuid().safeParse(entry.name).success || + registered.has(entry.name) + ) { + continue + } + await this.cleanup(join(pendingRoot, entry.name)) + this.discardBackup(join(this.root, provider, `accounts.json.${entry.name}.rollback`)) + } + } catch { + console.warn('[managed-data-accounts] Could not retry private account directory cleanup.') + } + } +} diff --git a/src/main/managed-data-accounts/service.test.ts b/src/main/managed-data-accounts/service.test.ts index 8c247bbd5f1..5ab7f12d04c 100644 --- a/src/main/managed-data-accounts/service.test.ts +++ b/src/main/managed-data-accounts/service.test.ts @@ -1,4 +1,6 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { randomUUID } from 'node:crypto' +import * as fileSystem from 'node:fs' import { existsSync, mkdirSync, @@ -16,6 +18,10 @@ import SyncDatabase from '../sqlite/sync-database' import * as secureFile from '../../shared/secure-file' import { ManagedDataAccountService } from './service' +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal()) +})) + let root: string let source: string let service: ManagedDataAccountService @@ -91,16 +97,18 @@ describe('managed data accounts', () => { } ) - it('retains selected account metadata after locked cleanup and permits retry', async () => { + it('retains selected account metadata when the atomic rename is locked and permits retry', async () => { let locked = true - service = new ManagedDataAccountService(join(root, 'managed'), (directory) => { - if (locked) { - throw new Error('file locked') - } - rmSync(directory, { recursive: true, force: true }) - }) const before = await service.add('devin', source, 'Work') const environment = service.launchEnvironment('devin') + const directory = join(root, 'managed', 'devin', before.accounts[0].id) + const rename = fileSystem.renameSync + vi.spyOn(fileSystem, 'renameSync').mockImplementation((from, to) => { + if (locked && from === directory) { + throw new Error('file locked') + } + return rename(from, to) + }) const metadataPath = join(root, 'managed', 'devin', 'accounts.json') const metadata = readFileSync(metadataPath) const changed = vi.fn() @@ -119,6 +127,141 @@ describe('managed data accounts', () => { expect(changed).toHaveBeenCalledTimes(1) }) + it('preserves both transaction errors and a private recovery backup when metadata rollback fails', async () => { + const before = await service.add('devin', source, 'Work') + const id = before.accounts[0].id + const directory = join(root, 'managed', 'devin', id) + const credentialsPath = join(directory, 'data', 'devin', 'credentials.toml') + const credentials = readFileSync(credentialsPath) + const metadataPath = join(root, 'managed', 'devin', 'accounts.json') + const metadata = readFileSync(metadataPath) + const originalError = Object.assign(new Error('injected quarantine rename failure'), { + code: 'EPERM' + }) + const rollbackError = Object.assign(new Error('injected metadata rollback failure'), { + code: 'EACCES' + }) + const rename = fileSystem.renameSync + const failingRename = vi.spyOn(fileSystem, 'renameSync').mockImplementation((from, to) => { + if (from === directory) { + throw originalError + } + if (typeof from === 'string' && from.endsWith('.rollback')) { + throw rollbackError + } + return rename(from, to) + }) + const changed = vi.fn() + service.onChanged(changed) + let failure: unknown + try { + await service.remove('devin', id) + } catch (error) { + failure = error + } + expect(failure).toBeInstanceOf(AggregateError) + if (!(failure instanceof AggregateError)) { + throw new Error('Expected both removal and rollback failures.') + } + expect(failure.errors).toEqual([originalError, rollbackError]) + expect(failure.cause).toBe(originalError) + expect(readFileSync(`${metadataPath}.${id}.rollback`)).toEqual(metadata) + expect(readFileSync(credentialsPath)).toEqual(credentials) + expect(service.list('devin')).toEqual({ accounts: [], activeAccountId: null }) + expect(service.launchEnvironment('devin')).toEqual({}) + expect(changed).not.toHaveBeenCalled() + + failingRename.mockRestore() + await expect(service.remove('devin', id)).resolves.toEqual({ + accounts: [], + activeAccountId: null + }) + expect(existsSync(directory)).toBe(false) + expect(existsSync(`${metadataPath}.${id}.rollback`)).toBe(false) + expect(changed).toHaveBeenCalledTimes(1) + }) + + it('commits logical removal without reselecting a partially deleted profile and retries cleanup', async () => { + let locked = true + let cleanupDirectory: string | undefined + service = new ManagedDataAccountService(join(root, 'managed'), (directory) => { + cleanupDirectory = directory + if (locked) { + rmSync(join(directory, 'data'), { recursive: true, force: true }) + throw Object.assign(new Error('state file locked after credential deletion'), { + code: 'EPERM' + }) + } + rmSync(directory, { recursive: true, force: true }) + }) + const before = await service.add('devin', source, 'Work') + const id = before.accounts[0].id + const environment = service.launchEnvironment('devin') + mkdirSync(environment.XDG_STATE_HOME, { recursive: true }) + writeFileSync(join(environment.XDG_STATE_HOME, 'locked-file'), 'remaining private state') + const changed = vi.fn() + service.onChanged(changed) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect(service.remove('devin', id)).resolves.toEqual({ + accounts: [], + activeAccountId: null + }) + expect(service.list('devin')).toEqual({ accounts: [], activeAccountId: null }) + expect(service.launchEnvironment('devin')).toEqual({}) + expect(service.transcriptEnvironments('devin')).toEqual([]) + expect(cleanupDirectory).toBeDefined() + expect(cleanupDirectory).not.toBe(join(root, 'managed', 'devin', id)) + expect(existsSync(join(environment.XDG_DATA_HOME, 'devin', 'credentials.toml'))).toBe(false) + expect(changed).toHaveBeenCalledTimes(1) + + locked = false + await expect(service.remove('devin', id)).resolves.toEqual({ + accounts: [], + activeAccountId: null + }) + expect(cleanupDirectory && existsSync(cleanupDirectory)).toBe(false) + expect(changed).toHaveBeenCalledTimes(1) + expect(readFileSync(join(source, 'devin', 'credentials.toml'), 'utf8')).toContain( + 'test-only-key' + ) + }) + + it('retries quarantined cleanup on restart without reviving a removed account', async () => { + service = new ManagedDataAccountService(join(root, 'managed'), () => { + throw new Error('injected cleanup lock') + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const before = await service.add('devin', source, 'Work') + const id = before.accounts[0].id + await service.remove('devin', id) + const pendingDirectory = join(root, 'managed', 'devin', '.pending-delete', id) + expect(existsSync(pendingDirectory)).toBe(true) + + const restarted = new ManagedDataAccountService(join(root, 'managed')) + expect(restarted.list('devin')).toEqual({ accounts: [], activeAccountId: null }) + expect(restarted.launchEnvironment('devin')).toEqual({}) + await vi.waitFor(() => expect(existsSync(pendingDirectory)).toBe(false)) + expect(restarted.list('devin')).toEqual({ accounts: [], activeAccountId: null }) + }) + + it('retries every eligible quarantine while preserving registered and unrecognized directories', async () => { + const before = await service.add('devin', source, 'Work') + const pendingRoot = join(root, 'managed', 'devin', '.pending-delete') + const pendingIds = Array.from({ length: 65 }, () => randomUUID()) + for (const id of [...pendingIds, before.accounts[0].id, 'unrecognized']) { + mkdirSync(join(pendingRoot, id), { recursive: true }) + } + const restarted = new ManagedDataAccountService(join(root, 'managed')) + await vi.waitFor(() => + expect(readdirSync(pendingRoot).sort()).toEqual( + [before.accounts[0].id, 'unrecognized'].sort() + ) + ) + expect(restarted.list('devin')).toEqual(before) + expect(restarted.launchEnvironment('devin')).toEqual(service.launchEnvironment('devin')) + }) + 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 @@ -167,6 +310,129 @@ describe('managed data accounts', () => { } }) + it.each(['session_v2', 'session_message'])( + 'rejects %s rows committed after source validation before the real SQLite backup', + async (table) => { + openCodeSource('session_v2') + const writer = new SyncDatabase(join(source, 'opencode', 'opencode.db')) + writer.pragma('journal_mode = WAL') + writer.exec('CREATE TABLE session_message (data TEXT)') + const backup = SyncDatabase.prototype.backup + const backupSpy = vi.spyOn(SyncDatabase.prototype, 'backup').mockImplementation(function ( + this: SyncDatabase, + destination, + options + ) { + writer.prepare(`INSERT INTO ${table} VALUES (?)`).run('injected conversation after audit') + return backup.call(this, destination, options) + }) + try { + await expect(service.add('opencode', source, 'Work')).rejects.toThrow( + 'conversation databases' + ) + expect(backupSpy).toHaveBeenCalledTimes(1) + expect(service.list('opencode')).toEqual({ accounts: [], activeAccountId: null }) + expect(readdirSync(join(root, 'managed', 'opencode'))).toEqual([]) + expect(writer.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get()?.count).toBe(1) + } finally { + writer.close() + } + } + ) + + it('rejects conversation committed by a second WAL writer during asynchronous backup', async () => { + openCodeSource('session_v2') + const writer = new SyncDatabase(join(source, 'opencode', 'opencode.db')) + writer.pragma('journal_mode = WAL') + writer.exec('CREATE TABLE padding (data BLOB); INSERT INTO padding VALUES (zeroblob(65536))') + let mutated = false + const backup = SyncDatabase.prototype.backup + vi.spyOn(SyncDatabase.prototype, 'backup').mockImplementation(function ( + this: SyncDatabase, + destination, + options + ) { + return backup.call(this, destination, { + ...options, + rate: 1, + progress: ({ remainingPages }) => { + if (!mutated && remainingPages > 0) { + writer.prepare('INSERT INTO session_v2 VALUES (?)').run('injected concurrent session') + mutated = true + } + } + }) + }) + try { + await expect(service.add('opencode', source, 'Work')).rejects.toThrow( + 'conversation databases' + ) + expect(mutated).toBe(true) + expect(service.list('opencode')).toEqual({ accounts: [], activeAccountId: null }) + expect(readdirSync(join(root, 'managed', 'opencode'))).toEqual([]) + } finally { + writer.close() + } + }) + + it('publishes integrations from the completed credential snapshot', async () => { + openCodeSource('session_v2') + const writer = new SyncDatabase(join(source, 'opencode', 'opencode.db')) + writer.pragma('journal_mode = WAL') + const backup = SyncDatabase.prototype.backup + vi.spyOn(SyncDatabase.prototype, 'backup').mockImplementation(function ( + this: SyncDatabase, + destination, + options + ) { + writer + .prepare('INSERT INTO credential VALUES (?, ?)') + .run('google', JSON.stringify({ type: 'key', key: 'injected-new-test-credential' })) + return backup.call(this, destination, options) + }) + try { + const state = await service.add('opencode', source, 'Work') + expect(state.accounts[0].integrations).toEqual(['opencode-go', 'google']) + } finally { + writer.close() + } + }) + + it.each(['empty', 'invalid'])( + 'rejects %s credentials committed after source validation and preserves the selected account', + async (change) => { + openCodeSource('session_v2') + const before = await service.add('opencode', source, 'Existing') + const writer = new SyncDatabase(join(source, 'opencode', 'opencode.db')) + writer.pragma('journal_mode = WAL') + const backup = SyncDatabase.prototype.backup + vi.spyOn(SyncDatabase.prototype, 'backup').mockImplementation(function ( + this: SyncDatabase, + destination, + options + ) { + writer.exec('DELETE FROM credential') + if (change === 'invalid') { + writer + .prepare('INSERT INTO credential VALUES (?, ?)') + .run('google', '{"type":"key","key":""}') + } + return backup.call(this, destination, options) + }) + try { + await expect(service.add('opencode', source, 'Rejected')).rejects.toThrow( + change === 'empty' ? 'supported credential' : 'credential format' + ) + expect(service.list('opencode')).toEqual(before) + expect(readdirSync(join(root, 'managed', 'opencode')).sort()).toEqual( + [before.accounts[0].id, 'accounts.json'].sort() + ) + } 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')) diff --git a/src/main/managed-data-accounts/service.ts b/src/main/managed-data-accounts/service.ts index 4b51caa2742..180565d23d4 100644 --- a/src/main/managed-data-accounts/service.ts +++ b/src/main/managed-data-accounts/service.ts @@ -1,13 +1,5 @@ import { randomUUID } from 'node:crypto' -import { - existsSync, - lstatSync, - mkdirSync, - readFileSync, - realpathSync, - renameSync, - rmSync -} from 'node:fs' +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' @@ -17,6 +9,7 @@ import type { ManagedDataAccountsState } from '../../shared/managed-account-types' import { captureDataAccountCredentials } from './credential-capture' +import { ManagedDataAccountProfileRemoval } from './profile-removal' import { captureManagedDataAccountOriginalEnvironment, restoreManagedDataAccountEnvironment @@ -45,12 +38,30 @@ export class ManagedDataAccountService { private pending: Promise = Promise.resolve() private readonly listeners = new Set<() => void>() private readonly inlineAuthBaselines = new Map() + private readonly profileRemoval: ManagedDataAccountProfileRemoval constructor( private readonly root: string, - private readonly removeDirectory: (directory: string) => void = (directory) => - rmSync(directory, { recursive: true, force: true }) - ) {} + removeDirectory?: (directory: string) => void | Promise + ) { + this.profileRemoval = new ManagedDataAccountProfileRemoval( + root, + (path) => this.assertOwned(path), + removeDirectory + ) + if (existsSync(root)) { + for (const provider of ['opencode', 'devin'] as const) { + void this.mutate(async () => { + const registered = new Set(this.list(provider).accounts.map((account) => account.id)) + await this.profileRemoval.retry(provider, registered) + }).catch(() => { + console.warn( + '[managed-data-accounts] Could not read accounts for private directory cleanup.' + ) + }) + } + } + } list(provider: ManagedDataAccountProvider): ManagedDataAccountsState { const path = join(this.root, provider, 'accounts.json') @@ -109,41 +120,15 @@ export class ManagedDataAccountService { provider: ManagedDataAccountProvider, accountId: string ): Promise { - return this.mutate(async () => { - const state = this.list(provider) - if (!state.accounts.some((account) => account.id === accountId)) { - throw new Error('Managed account not found.') - } - const directory = join(this.root, provider, accountId) - if (existsSync(directory)) { - this.assertOwned(directory) - } - const metadataPath = join(this.root, provider, 'accounts.json') - const rollbackPath = `${metadataPath}.${randomUUID()}.rollback` - if (!writeSecureFile(rollbackPath, readFileSync(metadataPath, 'utf8'), { durable: true })) { - rmSync(rollbackPath, { force: true }) - throw new Error('Could not restrict account metadata backup permissions.') - } - let next: ManagedDataAccountsState - try { - // Keep the original metadata until persistence and cleanup both succeed. - next = this.writeState(provider, { - accounts: state.accounts.filter((account) => account.id !== accountId), - activeAccountId: state.activeAccountId === accountId ? null : state.activeAccountId - }) - this.removeDirectory(directory) - } catch (error) { - renameSync(rollbackPath, metadataPath) - throw error - } - try { - rmSync(rollbackPath, { force: true }) - } catch { - console.warn('[managed-data-accounts] Could not remove account metadata backup.') - } - this.notifyChanged() - return next - }) + return this.mutate(() => + this.profileRemoval.remove( + provider, + accountId, + this.list(provider), + (next) => this.writeState(provider, next), + () => this.notifyChanged() + ) + ) } launchEnvironment(provider: ManagedDataAccountProvider): Record {