From f98f17ec676dc62aaa9564756ccdcbcc7ffae4be Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:18:07 -0700 Subject: [PATCH 01/24] 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 a64d7e212ad8..20da9dbc59a4 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 b0f724a3eccb..45b913f63cd0 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 bf1f1e313d1e..c27ea5108263 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 000000000000..2b76d7a0f1e8 --- /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 b2a06c6d2e25..2fdc394b87ea 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 000000000000..a440b9ccf88a --- /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 000000000000..0642992238f1 --- /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 8fc3b073faf9..c2153e665e4c 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 2a6c014b984e..259fce08ccab 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 ac2a322fd864..6d6a73537e2f 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 7e285cf3b978..27fd5568b36a 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 69dd0896e8de..0f1cc0306ec0 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 bfbd9b420e26..2d161a012898 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 12d37e0f724b..2187aaca0f02 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 d947f956e481..fd151e03b12e 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 93fb79a907a9..7172b28d2dc2 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 4299ebb42b50..102534564b78 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 76f05e9ddf58..6ae5c7f4c63d 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 bec472940442..472c3fe0ff9b 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 324dbe60dc7a..1cae20bfcbd2 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 000000000000..22de0bbe2e28 --- /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 000000000000..382addedef3d --- /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 000000000000..76cc162ea4a2 --- /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 000000000000..0c053333f82f --- /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 fb406dc7a796..3525bfbdc84a 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 4994003eef24..94a8db3205d1 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 ae3508289c99..c8c4409abc97 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 0dd39fa2a673..57d9c2959a0f 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 0bbcd0712ea0..d16715ed4966 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 ac8948422ac5..b8473d00e014 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 f7fe0af90ecc..9186e378da24 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 45d3ade97ff7..e4f1cbfe8b5d 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 82b3204da522..fb993e625cd8 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 95518967299e..1a883e9f769d 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 36cd837e4fd9..9e86ebdd15ca 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 4fb4f8d01cf2..d6d6ef39a30d 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 7239c62e6ad4..a4f7c272ac62 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 000000000000..357248220cac --- /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 5d3217f00f3d..492436f23c1d 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 0a9559e95497..7fa31ee6b804 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 9b6680b08c28..ca0ac8e33dcd 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/24] 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 000000000000..a8db7c6762f9 --- /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 1e479ab7e25a..557cf4af122b 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 000000000000..0f0fdbecedde --- /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 2f29e560803a..c37adaa1122e 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 45afe6f04a07..40fa622722b8 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 85a1b64c154a..46ee70baf12d 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 0bed9bab4225..8fe5e4b07ab4 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 a503c3347d81..ba4450935243 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 63fc2b87de52..f443dc9acd5a 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/24] 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 76cc162ea4a2..8b7a2ad44256 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 0c053333f82f..0191a83124b2 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/24] 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 c2153e665e4c..eafe97532719 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 27fd5568b36a..91dc2cf48d50 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/24] 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 94a8db3205d1..383841709a6f 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 c8c4409abc97..875c18fa8b5f 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/24] 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 a063ffb3dd60..d28c61752a7f 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 d0a3ba8cef16..411cb14f2871 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 5f64779b364d..56697f94badb 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 000000000000..85e5a669626b --- /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 382addedef3d..8546b001cf96 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 8b7a2ad44256..d9ad4cd2c12e 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 0191a83124b2..4549795f6eb0 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 383841709a6f..d3ea8c87e230 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 875c18fa8b5f..d55020551eff 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 645fcacb2d0d..2d4694ba5eaf 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 fbad4a212a0d..46acda30720e 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 000000000000..8bfeca0af8da --- /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/24] 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 46acda30720e..199cff6379ba 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 7903f1cddbc08fe1179f8bd964fff3dcb0e5d020 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:01:34 -0700 Subject: [PATCH 08/24] fix(opencode): keep saved Go credentials in main-owned storage Co-authored-by: kespineira Co-authored-by: kevimux --- .../api-key-file-unreadable-error.ts | 2 + .../encrypted-api-key-file-store.ts | 177 ++++++++++++++++ ...edential-change-rate-limit-refresh.test.ts | 40 ++++ .../credential-change-rate-limit-refresh.ts | 19 ++ src/main/ipc/minimax-credentials.ts | 12 +- src/main/ipc/opencode-go-credentials.test.ts | 87 ++++++++ src/main/ipc/opencode-go-credentials.ts | 47 ++++ .../register-core-handlers.test.ts | 8 + .../register-core-handlers.ts | 2 + .../minimax/minimax-api-key-store.test.ts | 25 ++- src/main/minimax/minimax-api-key-store.ts | 161 ++------------ .../opencode-go-api-key-store.test.ts | 99 +++++++++ .../opencode/opencode-go-api-key-store.ts | 13 ++ .../applying-settings/settings-update.ts | 3 - .../terminal-settings-migrations.ts | 4 + .../leasing-ssh-ptys/secret-validation.ts | 7 - ...gacy-opencode-go-api-key-migration.test.ts | 200 ++++++++++++++++++ .../legacy-opencode-go-api-key-migration.ts | 100 +++++++++ .../loading-store/loaded-state-parsing.ts | 10 +- .../state-serialization-secret-handling.ts | 8 +- .../state-write-round-trip.test.ts | 66 +++++- src/main/persistence/loading-store/store.ts | 11 + src/main/protected-secret-persistence.ts | 12 +- .../opencode-go-api-key-source.test.ts | 30 ++- .../rate-limits/opencode-go-api-key-source.ts | 18 +- .../service-opencode-go-credentials.test.ts | 195 +++++++++++++++++ .../service-refresh-orchestration.test.ts | 12 +- .../service/service-account-refresh.ts | 13 ++ .../service/service-configuration.ts | 6 +- .../service/service-fetch-targets.ts | 30 +++ .../service/service-full-cycle-preparation.ts | 34 ++- src/main/rate-limits/service/service-state.ts | 1 + src/main/rate-limits/service/service-types.ts | 9 +- .../startup/main-process-account-services.ts | 24 ++- src/preload/api-types.ts | 5 + .../api/opencode-go-credentials-bridge.ts | 11 + src/preload/index.ts | 2 + src/preload/opencode-go-credentials.test.ts | 23 ++ ...accounts-pane-opencode-credentials.test.ts | 47 ++++ .../accounts-pane-opencode-credentials.tsx | 113 ++++++++++ ...ccounts-pane-provider-setting-sections.tsx | 54 +---- .../web/preload-api/web-agent-accounts-api.ts | 10 + .../web-preload-api-agent-providers.test.ts | 11 + .../web/web-preload-api-composition.test.ts | 1 + src/renderer/src/web/web-preload-api.ts | 2 + src/shared/default-global-settings.ts | 1 - src/shared/global-settings-types.ts | 2 - 47 files changed, 1504 insertions(+), 263 deletions(-) create mode 100644 src/main/credentials/api-key-file-unreadable-error.ts create mode 100644 src/main/credentials/encrypted-api-key-file-store.ts create mode 100644 src/main/ipc/credential-change-rate-limit-refresh.test.ts create mode 100644 src/main/ipc/credential-change-rate-limit-refresh.ts create mode 100644 src/main/ipc/opencode-go-credentials.test.ts create mode 100644 src/main/ipc/opencode-go-credentials.ts create mode 100644 src/main/opencode/opencode-go-api-key-store.test.ts create mode 100644 src/main/opencode/opencode-go-api-key-store.ts create mode 100644 src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.test.ts create mode 100644 src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.ts create mode 100644 src/main/rate-limits/service-opencode-go-credentials.test.ts create mode 100644 src/preload/api/opencode-go-credentials-bridge.ts create mode 100644 src/preload/opencode-go-credentials.test.ts create mode 100644 src/renderer/src/components/settings/accounts-pane-opencode-credentials.test.ts create mode 100644 src/renderer/src/components/settings/accounts-pane-opencode-credentials.tsx diff --git a/src/main/credentials/api-key-file-unreadable-error.ts b/src/main/credentials/api-key-file-unreadable-error.ts new file mode 100644 index 000000000000..9c1af30dc65f --- /dev/null +++ b/src/main/credentials/api-key-file-unreadable-error.ts @@ -0,0 +1,2 @@ +/** A transient read failure; the saved key may be fine, so callers must not ask to re-enter it. */ +export class ApiKeyFileUnreadableError extends Error {} diff --git a/src/main/credentials/encrypted-api-key-file-store.ts b/src/main/credentials/encrypted-api-key-file-store.ts new file mode 100644 index 000000000000..8d6e9f68f7b3 --- /dev/null +++ b/src/main/credentials/encrypted-api-key-file-store.ts @@ -0,0 +1,177 @@ +import { safeStorage } from 'electron' +import { existsSync, readFileSync, rmSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { + hardenExistingSecureFile, + isUnreadableError, + writeSecureFile +} from '../../shared/secure-file' +import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' +import { ApiKeyFileUnreadableError } from './api-key-file-unreadable-error' + +type EncryptedApiKeyFileStore = { + protection: () => SecretAtRestProtection | null + has: () => boolean + save: (key: string) => void + read: () => string | null + clear: () => void +} + +export function createEncryptedApiKeyFileStore({ + fileName, + envelopePrefix, + providerLabel, + logScope +}: { + fileName: string + envelopePrefix: string + providerLabel: string + logScope: string +}): EncryptedApiKeyFileStore { + let cachedApiKey: string | null = null + let warnedStatusHardenFailure = false + + type ApiKeyEnvelope = { + kind: 'encrypted' | 'plaintext' + payload: Buffer + } + + function getOrcaDir(): string { + return join(homedir(), '.orca') + } + + function getApiKeyPath(): string { + return join(getOrcaDir(), fileName) + } + + function encodeApiKeyEnvelope(kind: ApiKeyEnvelope['kind'], payload: Buffer): string { + return `${envelopePrefix}${kind}:${payload.toString('base64')}` + } + + function decodeApiKeyEnvelope(raw: Buffer): ApiKeyEnvelope { + const text = raw.toString('utf8') + if (!text.startsWith(envelopePrefix)) { + throw new Error(`${providerLabel} API key could not be decrypted`) + } + const rest = text.slice(envelopePrefix.length) + const separator = rest.indexOf(':') + if (separator === -1) { + throw new Error(`${providerLabel} API key could not be decrypted`) + } + const kind = rest.slice(0, separator) + if (kind !== 'encrypted' && kind !== 'plaintext') { + throw new Error(`${providerLabel} API key could not be decrypted`) + } + return { + kind, + payload: Buffer.from(rest.slice(separator + 1), 'base64') + } + } + + function readEnvelope(envelope: ApiKeyEnvelope): string { + if (envelope.kind === 'plaintext') { + return envelope.payload.toString('utf8') + } + if (!safeStorage.isEncryptionAvailable()) { + throw new Error(`${providerLabel} API key could not be decrypted`) + } + return safeStorage.decryptString(envelope.payload) + } + + function has(): boolean { + const keyPath = getApiKeyPath() + if (!existsSync(keyPath)) { + return false + } + try { + hardenExistingSecureFile(keyPath) + } catch (error) { + if (!warnedStatusHardenFailure) { + warnedStatusHardenFailure = true + console.warn( + `[${logScope}] Failed to harden ${providerLabel} API key file while checking status`, + error + ) + } + } + return true + } + + function protection(): SecretAtRestProtection | null { + const path = getApiKeyPath() + if (!existsSync(path)) { + return null + } + try { + return decodeApiKeyEnvelope(readFileSync(path)).kind === 'plaintext' ? 'plaintext' : 'sealed' + } catch { + return null + } + } + + function save(key: string): void { + const trimmed = key.trim() + if (!trimmed) { + throw new Error(`${providerLabel} API key is required`) + } + if (safeStorage.isEncryptionAvailable()) { + writeSecureFile( + getApiKeyPath(), + encodeApiKeyEnvelope('encrypted', safeStorage.encryptString(trimmed)), + { durable: true } + ) + cachedApiKey = trimmed + return + } + console.warn( + `[${logScope}] safeStorage encryption unavailable — storing ${providerLabel} API key in plaintext` + ) + writeSecureFile( + getApiKeyPath(), + encodeApiKeyEnvelope('plaintext', Buffer.from(trimmed, 'utf8')), + { durable: true } + ) + cachedApiKey = trimmed + } + + function read(): string | null { + if (cachedApiKey !== null) { + return cachedApiKey + } + const keyPath = getApiKeyPath() + if (!existsSync(keyPath)) { + return null + } + // Why: permission failures must not be reported as decryption failures. + try { + hardenExistingSecureFile(keyPath) + } catch (error) { + console.warn( + `[${logScope}] Failed to harden ${providerLabel} API key file while reading`, + error + ) + } + let raw: Buffer | null = null + try { + raw = readFileSync(keyPath) + const envelope = decodeApiKeyEnvelope(raw) + cachedApiKey = readEnvelope(envelope) + return cachedApiKey + } catch (error) { + if (raw === null && isUnreadableError(error)) { + console.warn(`[${logScope}] failed to read API key file`, error) + throw new ApiKeyFileUnreadableError(`${providerLabel} API key file could not be read`) + } + console.error(`[${logScope}] failed to decode/decrypt API key`, error) + throw new Error(`${providerLabel} API key could not be decrypted`) + } + } + + function clear(): void { + cachedApiKey = null + rmSync(getApiKeyPath(), { force: true }) + } + + return { has, save, read, clear, protection } +} diff --git a/src/main/ipc/credential-change-rate-limit-refresh.test.ts b/src/main/ipc/credential-change-rate-limit-refresh.test.ts new file mode 100644 index 000000000000..98b06918c709 --- /dev/null +++ b/src/main/ipc/credential-change-rate-limit-refresh.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createEmptyRateLimitState } from '../../shared/rate-limit-state-factory' +import { refreshAfterCredentialChange } from './credential-change-rate-limit-refresh' + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('refreshAfterCredentialChange', () => { + it('does nothing without a rate-limit service', () => { + const invalidate = vi.fn() + refreshAfterCredentialChange(null, invalidate, '[test] refresh failed:') + expect(invalidate).not.toHaveBeenCalled() + }) + + it('invalidates before refreshing', () => { + const order: string[] = [] + const service = { + refresh: vi.fn(async () => { + order.push('refresh') + return createEmptyRateLimitState() + }) + } + refreshAfterCredentialChange(service, () => order.push('invalidate'), '[test] refresh failed:') + expect(order).toEqual(['invalidate', 'refresh']) + }) + + it('logs a failed background refresh with the caller message', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const failure = new Error('network down') + const service = { + refresh: vi.fn(async () => { + throw failure + }) + } + + refreshAfterCredentialChange(service, () => {}, '[test] refresh failed:') + await vi.waitFor(() => expect(error).toHaveBeenCalledWith('[test] refresh failed:', failure)) + }) +}) diff --git a/src/main/ipc/credential-change-rate-limit-refresh.ts b/src/main/ipc/credential-change-rate-limit-refresh.ts new file mode 100644 index 000000000000..7e5bf3eaf3e2 --- /dev/null +++ b/src/main/ipc/credential-change-rate-limit-refresh.ts @@ -0,0 +1,19 @@ +import type { RateLimitService } from '../rate-limits/service' + +/** + * Drops a provider's stale usage, then refreshes in the background. + * Why fire-and-forget: callers return the persisted credential status immediately; a failed refresh only logs. + */ +export function refreshAfterCredentialChange>( + rateLimits: T | null, + invalidate: (rateLimits: T) => void, + failureLogMessage: string +): void { + if (!rateLimits) { + return + } + invalidate(rateLimits) + void rateLimits.refresh().catch((error: unknown) => { + console.error(failureLogMessage, error) + }) +} diff --git a/src/main/ipc/minimax-credentials.ts b/src/main/ipc/minimax-credentials.ts index cac2567db98c..c488c1d8defe 100644 --- a/src/main/ipc/minimax-credentials.ts +++ b/src/main/ipc/minimax-credentials.ts @@ -12,6 +12,7 @@ import { saveMiniMaxApiKey } from '../minimax/minimax-api-key-store' import { clearMiniMaxSessionCookieJar } from '../rate-limits/minimax/minimax-request-context' +import { refreshAfterCredentialChange } from './credential-change-rate-limit-refresh' import type { RateLimitService } from '../rate-limits/service' import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' @@ -36,16 +37,15 @@ function getMiniMaxCredentialsStatus(): MiniMaxCredentialsStatus { } } -// Why: fire-and-forget — callers get the persisted credential status immediately; -// the rate-limit refresh runs in the background and only logs on failure. function refreshAfterMiniMaxCredentialChange( rateLimits: RateLimitService | null, action: 'save' | 'clear' ): void { - rateLimits?.invalidateMiniMaxCredentialState() - void rateLimits?.refresh().catch((error: unknown) => { - console.error(`[minimax] failed to trigger rate-limit refresh after ${action}:`, error) - }) + refreshAfterCredentialChange( + rateLimits, + (service) => service.invalidateMiniMaxCredentialState(), + `[minimax] failed to trigger rate-limit refresh after ${action}:` + ) } export function registerMiniMaxCredentialsHandlers(rateLimits: RateLimitService | null): void { diff --git a/src/main/ipc/opencode-go-credentials.test.ts b/src/main/ipc/opencode-go-credentials.test.ts new file mode 100644 index 000000000000..a90438127b7c --- /dev/null +++ b/src/main/ipc/opencode-go-credentials.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createEmptyRateLimitState } from '../../shared/rate-limit-state-factory' +import { registerOpenCodeGoCredentialsHandlers } from './opencode-go-credentials' + +const mocks = vi.hoisted(() => ({ + handlers: new Map unknown>(), + has: vi.fn(() => false), + save: vi.fn(), + clear: vi.fn() +})) +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: (event: unknown, key?: unknown) => unknown) => { + mocks.handlers.set(channel, handler) + } + } +})) +vi.mock('../opencode/opencode-go-api-key-store', () => ({ + hasOpenCodeGoApiKey: mocks.has, + saveOpenCodeGoApiKey: mocks.save, + clearOpenCodeGoApiKey: mocks.clear +})) + +function invoke(action: string, value?: unknown): unknown { + const handler = mocks.handlers.get(`opencodeGoCredentials:${action}`) + if (!handler) { + throw new Error('Missing credential handler') + } + return handler({}, value) +} + +beforeEach(() => { + vi.resetAllMocks() + mocks.handlers.clear() +}) + +describe('OpenCode Go write-only credentials', () => { + it('returns only boolean status and provides no key reader', () => { + registerOpenCodeGoCredentialsHandlers(null) + mocks.has.mockReturnValue(true) + expect(invoke('getStatus')).toEqual({ apiKeyConfigured: true }) + expect([...mocks.handlers.keys()]).toEqual([ + 'opencodeGoCredentials:getStatus', + 'opencodeGoCredentials:saveApiKey', + 'opencodeGoCredentials:clearApiKey' + ]) + }) + + it.each(['saveApiKey', 'clearApiKey'])('invalidates before refreshing on %s', (action) => { + const invalidate = vi.fn() + const refresh = vi.fn(async () => { + expect(invalidate).toHaveBeenCalledOnce() + return createEmptyRateLimitState() + }) + registerOpenCodeGoCredentialsHandlers({ + invalidateOpenCodeGoCredentialState: invalidate, + refresh + }) + mocks.has.mockReturnValue(action === 'saveApiKey') + expect(invoke(action, 'fake-key')).toEqual({ apiKeyConfigured: action === 'saveApiKey' }) + expect(action === 'saveApiKey' ? mocks.save : mocks.clear).toHaveBeenCalledOnce() + expect(refresh).toHaveBeenCalledOnce() + // Why: only clearing the saved key may hide the chip; another key source can still exist after a save. + expect(invalidate).toHaveBeenCalledWith({ apiKeyCleared: action === 'clearApiKey' }) + }) + + it.each([null, undefined, 42, {}])('rejects a non-string key', (key) => { + registerOpenCodeGoCredentialsHandlers(null) + expect(() => invoke('saveApiKey', key)).toThrow('OpenCode Go API key must be a string') + expect(mocks.save).not.toHaveBeenCalled() + }) + + it('does not refresh after a failed save', () => { + const invalidate = vi.fn() + const refresh = vi.fn() + registerOpenCodeGoCredentialsHandlers({ + invalidateOpenCodeGoCredentialState: invalidate, + refresh + }) + mocks.save.mockImplementation(() => { + throw new Error('Could not save credential') + }) + expect(() => invoke('saveApiKey', 'fake-key')).toThrow('Could not save credential') + expect(invalidate).not.toHaveBeenCalled() + expect(refresh).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/opencode-go-credentials.ts b/src/main/ipc/opencode-go-credentials.ts new file mode 100644 index 000000000000..58788257714c --- /dev/null +++ b/src/main/ipc/opencode-go-credentials.ts @@ -0,0 +1,47 @@ +import { ipcMain } from 'electron' +import { + clearOpenCodeGoApiKey, + hasOpenCodeGoApiKey, + saveOpenCodeGoApiKey +} from '../opencode/opencode-go-api-key-store' +import type { RateLimitService } from '../rate-limits/service' +import { refreshAfterCredentialChange } from './credential-change-rate-limit-refresh' + +type CredentialRateLimits = Pick< + RateLimitService, + 'invalidateOpenCodeGoCredentialState' | 'refresh' +> + +function getOpenCodeGoCredentialsStatus(): { apiKeyConfigured: boolean } { + return { apiKeyConfigured: hasOpenCodeGoApiKey() } +} + +function refreshAfterOpenCodeGoCredentialChange( + rateLimits: CredentialRateLimits | null, + apiKeyCleared: boolean +): void { + refreshAfterCredentialChange( + rateLimits, + (service) => service.invalidateOpenCodeGoCredentialState({ apiKeyCleared }), + '[opencode-go] failed to refresh usage after a credential change:' + ) +} + +export function registerOpenCodeGoCredentialsHandlers( + rateLimits: CredentialRateLimits | null +): void { + ipcMain.handle('opencodeGoCredentials:getStatus', () => getOpenCodeGoCredentialsStatus()) + ipcMain.handle('opencodeGoCredentials:saveApiKey', (_event, key: unknown) => { + if (typeof key !== 'string') { + throw new Error('OpenCode Go API key must be a string') + } + saveOpenCodeGoApiKey(key) + refreshAfterOpenCodeGoCredentialChange(rateLimits, false) + return getOpenCodeGoCredentialsStatus() + }) + ipcMain.handle('opencodeGoCredentials:clearApiKey', () => { + clearOpenCodeGoApiKey() + refreshAfterOpenCodeGoCredentialChange(rateLimits, true) + return getOpenCodeGoCredentialsStatus() + }) +} diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index 4e67441ea343..6c938a8239fb 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -35,6 +35,7 @@ const { registerCodexAccountHandlersMock, registerAgentHookHandlersMock, registerClaudeAccountHandlersMock, + registerOpenCodeGoCredentialsHandlersMock, registerMiniMaxCredentialsHandlersMock, registerGrokAccountHandlersMock, registerCursorAccountHandlersMock, @@ -102,6 +103,7 @@ const { registerCodexAccountHandlersMock: vi.fn(), registerAgentHookHandlersMock: vi.fn(), registerClaudeAccountHandlersMock: vi.fn(), + registerOpenCodeGoCredentialsHandlersMock: vi.fn(), registerMiniMaxCredentialsHandlersMock: vi.fn(), registerGrokAccountHandlersMock: vi.fn(), registerCursorAccountHandlersMock: vi.fn(), @@ -332,6 +334,10 @@ vi.mock('../claude-accounts', () => ({ registerClaudeAccountHandlers: registerClaudeAccountHandlersMock })) +vi.mock('../opencode-go-credentials', () => ({ + registerOpenCodeGoCredentialsHandlers: registerOpenCodeGoCredentialsHandlersMock +})) + vi.mock('../minimax-credentials', () => ({ registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock })) @@ -440,6 +446,7 @@ describe('registerCoreHandlers', () => { registerCodexAccountHandlersMock.mockReset() registerAgentHookHandlersMock.mockReset() registerClaudeAccountHandlersMock.mockReset() + registerOpenCodeGoCredentialsHandlersMock.mockReset() registerMiniMaxCredentialsHandlersMock.mockReset() registerClipboardHandlersMock.mockReset() setTrustedClipboardRendererWebContentsIdMock.mockReset() @@ -539,6 +546,7 @@ describe('registerCoreHandlers', () => { ) expect(registerPetHandlersMock).toHaveBeenCalled() expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) + expect(registerOpenCodeGoCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerGrokAccountHandlersMock).toHaveBeenCalled() expect(registerCursorAccountHandlersMock).toHaveBeenCalled() diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index d3a647cafd71..5105b8992e63 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -61,6 +61,7 @@ import { registerAgentHookHandlers } from '../agent-hooks' import { registerCodexConfigSyncHandlers } from '../codex-config-sync' import { getPtyIdForPaneKey } from '../pty' import { registerClaudeAccountHandlers } from '../claude-accounts' +import { registerOpenCodeGoCredentialsHandlers } from '../opencode-go-credentials' import { registerMiniMaxCredentialsHandlers } from '../minimax-credentials' import { registerGrokAccountHandlers } from '../grok-accounts' import { registerCursorAccountHandlers } from '../cursor-accounts' @@ -150,6 +151,7 @@ export function registerCoreHandlers( registerAgentHookHandlers(runtime, { getPtyIdForPaneKey }) registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService) registerClaudeAccountHandlers(claudeAccounts) + registerOpenCodeGoCredentialsHandlers(rateLimits) registerMiniMaxCredentialsHandlers(rateLimits) registerGrokAccountHandlers() registerCursorAccountHandlers() diff --git a/src/main/minimax/minimax-api-key-store.test.ts b/src/main/minimax/minimax-api-key-store.test.ts index 756ed9069b1b..90ac8012fc53 100644 --- a/src/main/minimax/minimax-api-key-store.test.ts +++ b/src/main/minimax/minimax-api-key-store.test.ts @@ -36,6 +36,8 @@ vi.mock('node:path', () => ({ vi.mock('../../shared/secure-file', () => ({ hardenExistingSecureFile: hardenExistingSecureFileMock, + isUnreadableError: (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'EBUSY', writeSecureFile: writeSecureFileMock })) @@ -102,7 +104,8 @@ describe('minimax-api-key-store', () => { expect(safeStorageMock.encryptString).toHaveBeenCalledWith('sk-test-1234567890') expect(writeSecureFileMock).toHaveBeenCalledWith( storePath, - envelope('encrypted', 'sk-test-1234567890') + envelope('encrypted', 'sk-test-1234567890'), + { durable: true } ) }) @@ -114,7 +117,8 @@ describe('minimax-api-key-store', () => { store.saveMiniMaxApiKey('sk-test-1234567890') expect(writeSecureFileMock).toHaveBeenCalledWith( storePath, - envelope('plaintext', 'sk-test-1234567890') + envelope('plaintext', 'sk-test-1234567890'), + { durable: true } ) expect(warn).toHaveBeenCalledWith(expect.stringContaining('safeStorage encryption unavailable')) warn.mockRestore() @@ -211,6 +215,23 @@ describe('minimax-api-key-store', () => { expect(() => store.readMiniMaxApiKey()).toThrow(/could not be decrypted/) }) + it('reports a transient read failure as unreadable, not undecryptable, and retries next read', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockImplementationOnce(() => { + throw Object.assign(new Error('resource busy'), { code: 'EBUSY' }) + }) + readFileSyncMock.mockReturnValueOnce(Buffer.from(envelope('encrypted', 'encrypted-payload'))) + safeStorageMock.decryptString.mockReturnValueOnce('sk-after-retry') + const store = await loadStore() + expect(() => store.readMiniMaxApiKey()).toThrow('MiniMax API key file could not be read') + expect(error).not.toHaveBeenCalled() + expect(store.readMiniMaxApiKey()).toBe('sk-after-retry') + warn.mockRestore() + error.mockRestore() + }) + it('clears the cached key and removes the file', async () => { existsSyncMock.mockReturnValueOnce(true) readFileSyncMock.mockReturnValueOnce(Buffer.from(envelope('encrypted', 'encrypted-payload'))) diff --git a/src/main/minimax/minimax-api-key-store.ts b/src/main/minimax/minimax-api-key-store.ts index c2fea05621fa..ac18c8822b3f 100644 --- a/src/main/minimax/minimax-api-key-store.ts +++ b/src/main/minimax/minimax-api-key-store.ts @@ -1,147 +1,14 @@ -import { safeStorage } from 'electron' -import { existsSync, readFileSync, rmSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' -import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-file' -import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' - -const MINIMAX_API_KEY_FILE = 'minimax-api-key.enc' -const API_KEY_ENVELOPE_PREFIX = 'orca-minimax-api-key:v1:' -let cachedMiniMaxApiKey: string | null = null -let warnedMiniMaxApiKeyStatusHardenFailure = false - -type MiniMaxApiKeyEnvelope = { - kind: 'encrypted' | 'plaintext' - payload: Buffer -} - -function getOrcaDir(): string { - return join(homedir(), '.orca') -} - -function getMiniMaxApiKeyPath(): string { - return join(getOrcaDir(), MINIMAX_API_KEY_FILE) -} - -function encodeApiKeyEnvelope(kind: MiniMaxApiKeyEnvelope['kind'], payload: Buffer): string { - return `${API_KEY_ENVELOPE_PREFIX}${kind}:${payload.toString('base64')}` -} - -function decodeApiKeyEnvelope(raw: Buffer): MiniMaxApiKeyEnvelope { - const text = raw.toString('utf8') - if (!text.startsWith(API_KEY_ENVELOPE_PREFIX)) { - throw new Error('MiniMax API key could not be decrypted') - } - const rest = text.slice(API_KEY_ENVELOPE_PREFIX.length) - const separator = rest.indexOf(':') - if (separator === -1) { - throw new Error('MiniMax API key could not be decrypted') - } - const kind = rest.slice(0, separator) - if (kind !== 'encrypted' && kind !== 'plaintext') { - throw new Error('MiniMax API key could not be decrypted') - } - return { - kind, - payload: Buffer.from(rest.slice(separator + 1), 'base64') - } -} - -function readEnvelope(envelope: MiniMaxApiKeyEnvelope): string { - if (envelope.kind === 'plaintext') { - return envelope.payload.toString('utf8') - } - if (!safeStorage.isEncryptionAvailable()) { - throw new Error('MiniMax API key could not be decrypted') - } - return safeStorage.decryptString(envelope.payload) -} - -export function hasMiniMaxApiKey(): boolean { - const keyPath = getMiniMaxApiKeyPath() - if (!existsSync(keyPath)) { - return false - } - try { - hardenExistingSecureFile(keyPath) - } catch (error) { - if (!warnedMiniMaxApiKeyStatusHardenFailure) { - warnedMiniMaxApiKeyStatusHardenFailure = true - console.warn('[minimax] Failed to harden MiniMax API key file while checking status', error) - } - } - return true -} - -/** - * How the stored key is protected, or null when none is stored. - * - * Reads the envelope kind only — no decrypt, so this cannot trigger a keychain prompt - * and is safe to call from a status handler. - */ -export function getMiniMaxApiKeyProtection(): SecretAtRestProtection | null { - const keyPath = getMiniMaxApiKeyPath() - if (!existsSync(keyPath)) { - return null - } - try { - return decodeApiKeyEnvelope(readFileSync(keyPath)).kind === 'plaintext' ? 'plaintext' : 'sealed' - } catch { - // An undecodable envelope is a decrypt-time error to report, not a protection claim. - return null - } -} - -export function saveMiniMaxApiKey(key: string): void { - const trimmed = key.trim() - if (!trimmed) { - throw new Error('MiniMax API key is required') - } - if (safeStorage.isEncryptionAvailable()) { - writeSecureFile( - getMiniMaxApiKeyPath(), - encodeApiKeyEnvelope('encrypted', safeStorage.encryptString(trimmed)) - ) - cachedMiniMaxApiKey = trimmed - return - } - console.warn( - '[minimax] safeStorage encryption unavailable — storing MiniMax API key in plaintext' - ) - writeSecureFile( - getMiniMaxApiKeyPath(), - encodeApiKeyEnvelope('plaintext', Buffer.from(trimmed, 'utf8')) - ) - cachedMiniMaxApiKey = trimmed -} - -export function readMiniMaxApiKey(): string | null { - if (cachedMiniMaxApiKey !== null) { - return cachedMiniMaxApiKey - } - const keyPath = getMiniMaxApiKeyPath() - if (!existsSync(keyPath)) { - return null - } - // Why: keep hardening out of the decode/decrypt try below so a chmod/ACL - // failure isn't misreported as a decrypt failure (matches hasMiniMaxApiKey). - try { - hardenExistingSecureFile(keyPath) - } catch (error) { - console.warn('[minimax] Failed to harden MiniMax API key file while reading', error) - } - try { - const raw = readFileSync(keyPath) - const envelope = decodeApiKeyEnvelope(raw) - cachedMiniMaxApiKey = readEnvelope(envelope) - return cachedMiniMaxApiKey - } catch (error) { - console.error('[minimax] failed to decode/decrypt API key', error) - throw new Error('MiniMax API key could not be decrypted') - } -} - -export function clearMiniMaxApiKey(): void { - cachedMiniMaxApiKey = null - rmSync(getMiniMaxApiKeyPath(), { force: true }) -} +import { createEncryptedApiKeyFileStore } from '../credentials/encrypted-api-key-file-store' + +const store = createEncryptedApiKeyFileStore({ + fileName: 'minimax-api-key.enc', + envelopePrefix: 'orca-minimax-api-key:v1:', + providerLabel: 'MiniMax', + logScope: 'minimax' +}) + +export const hasMiniMaxApiKey = store.has +export const getMiniMaxApiKeyProtection = store.protection +export const saveMiniMaxApiKey = store.save +export const readMiniMaxApiKey = store.read +export const clearMiniMaxApiKey = store.clear diff --git a/src/main/opencode/opencode-go-api-key-store.test.ts b/src/main/opencode/opencode-go-api-key-store.test.ts new file mode 100644 index 000000000000..728665076f93 --- /dev/null +++ b/src/main/opencode/opencode-go-api-key-store.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as NodeFs from 'node:fs' +import type * as NodeOs from 'node:os' +import { join } from 'node:path' + +const home = vi.hoisted(() => { + const state: { directory: string; readError: Error | null } = { directory: '', readError: null } + return state +}) +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + homedir: () => home.directory +})) +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readFileSync: (...args: Parameters) => { + if (home.readError) { + throw home.readError + } + return actual.readFileSync(...args) + } + } +}) +vi.mock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (key: string) => Buffer.from(`encrypted:${key}`), + decryptString: (bytes: Buffer) => bytes.toString().slice('encrypted:'.length) + } +})) + +beforeEach(() => { + home.readError = null + home.directory = mkdtempSync(join(tmpdir(), 'orca-go-key-store-')) + vi.resetModules() +}) +afterEach(() => rmSync(home.directory, { recursive: true, force: true })) + +describe('OpenCode Go main-owned API key file', () => { + it('persists a versioned encrypted envelope, reads it after restart, and clears it', async () => { + const store = await import('./opencode-go-api-key-store') + expect(store.hasOpenCodeGoApiKey()).toBe(false) + store.saveOpenCodeGoApiKey(' fake-key ') + expect(store.hasOpenCodeGoApiKey()).toBe(true) + const path = join(home.directory, '.orca', 'opencode-go-api-key.enc') + expect(readFileSync(path, 'utf8')).toBe( + `orca-opencode-go-api-key:v1:encrypted:${Buffer.from('encrypted:fake-key').toString('base64')}` + ) + vi.resetModules() + const restarted = await import('./opencode-go-api-key-store') + expect(restarted.readOpenCodeGoApiKey()).toBe('fake-key') + restarted.clearOpenCodeGoApiKey() + expect(restarted.hasOpenCodeGoApiKey()).toBe(false) + expect(restarted.readOpenCodeGoApiKey()).toBeNull() + }) + + it('keeps the MiniMax cache and file independent', async () => { + const go = await import('./opencode-go-api-key-store') + const miniMax = await import('../minimax/minimax-api-key-store') + go.saveOpenCodeGoApiKey('fake-go') + miniMax.saveMiniMaxApiKey('fake-minimax') + expect(go.readOpenCodeGoApiKey()).toBe('fake-go') + expect(miniMax.readMiniMaxApiKey()).toBe('fake-minimax') + go.clearOpenCodeGoApiKey() + expect(miniMax.hasMiniMaxApiKey()).toBe(true) + expect(miniMax.readMiniMaxApiKey()).toBe('fake-minimax') + }) + + it('rejects empty keys and malformed envelopes without returning the key', async () => { + const store = await import('./opencode-go-api-key-store') + expect(() => store.saveOpenCodeGoApiKey(' ')).toThrow('required') + store.saveOpenCodeGoApiKey('fake-key') + writeFileSync(join(home.directory, '.orca', 'opencode-go-api-key.enc'), 'fake-invalid-envelope') + vi.resetModules() + const restarted = await import('./opencode-go-api-key-store') + expect(() => restarted.readOpenCodeGoApiKey()).toThrow( + 'OpenCode Go API key could not be decrypted' + ) + }) + + it('throws a distinct unreadable error for a transient read failure', async () => { + const store = await import('./opencode-go-api-key-store') + store.saveOpenCodeGoApiKey('fake-key') + vi.resetModules() + const restarted = await import('./opencode-go-api-key-store') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + home.readError = Object.assign(new Error('resource busy'), { code: 'EBUSY' }) + + expect(() => restarted.readOpenCodeGoApiKey()).toThrow( + 'OpenCode Go API key file could not be read' + ) + home.readError = null + expect(restarted.readOpenCodeGoApiKey()).toBe('fake-key') + }) +}) diff --git a/src/main/opencode/opencode-go-api-key-store.ts b/src/main/opencode/opencode-go-api-key-store.ts new file mode 100644 index 000000000000..212f91662550 --- /dev/null +++ b/src/main/opencode/opencode-go-api-key-store.ts @@ -0,0 +1,13 @@ +import { createEncryptedApiKeyFileStore } from '../credentials/encrypted-api-key-file-store' + +const store = createEncryptedApiKeyFileStore({ + fileName: 'opencode-go-api-key.enc', + envelopePrefix: 'orca-opencode-go-api-key:v1:', + providerLabel: 'OpenCode Go', + logScope: 'opencode-go' +}) + +export const hasOpenCodeGoApiKey = store.has +export const saveOpenCodeGoApiKey = store.save +export const readOpenCodeGoApiKey = store.read +export const clearOpenCodeGoApiKey = store.clear diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index 97190a6f8907..26703ab43965 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -60,9 +60,6 @@ export function updateSettings( if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) { operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie) } - if ('opencodeGoApiKey' in updates && !updates.opencodeGoApiKey) { - operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeGoApiKey) - } if ('httpProxyUrl' in updates && !updates.httpProxyUrl) { operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) } diff --git a/src/main/persistence/applying-settings/terminal-settings-migrations.ts b/src/main/persistence/applying-settings/terminal-settings-migrations.ts index 161afcd1de5e..551516e70b3a 100644 --- a/src/main/persistence/applying-settings/terminal-settings-migrations.ts +++ b/src/main/persistence/applying-settings/terminal-settings-migrations.ts @@ -60,6 +60,8 @@ type RetiredGlobalSettings = { terminalScrollbackBytes?: unknown enableGitHubAttribution?: unknown showAgentsSidebar?: unknown + // Why: #22551 kept this key in settings; it now lives in a main-owned store and must never ride along. + opencodeGoApiKey?: unknown } export function stripRetiredGlobalSettings( @@ -69,11 +71,13 @@ export function stripRetiredGlobalSettings( terminalScrollbackBytes: _legacyScrollbackBytes, enableGitHubAttribution: _legacyGitHubAttribution, showAgentsSidebar: _legacyShowAgentsSidebar, + opencodeGoApiKey: _legacyOpenCodeGoApiKey, ...rest } = (settings ?? {}) as Partial & RetiredGlobalSettings void _legacyScrollbackBytes void _legacyGitHubAttribution void _legacyShowAgentsSidebar + void _legacyOpenCodeGoApiKey return rest } diff --git a/src/main/persistence/leasing-ssh-ptys/secret-validation.ts b/src/main/persistence/leasing-ssh-ptys/secret-validation.ts index 8ead3d56fe38..33303b660c73 100644 --- a/src/main/persistence/leasing-ssh-ptys/secret-validation.ts +++ b/src/main/persistence/leasing-ssh-ptys/secret-validation.ts @@ -6,13 +6,6 @@ export function isLegacyOpenCodeSessionCookie(value: string): boolean { ) } -// OpenCode Go keys are opaque bearer tokens; the console issues `sk-` -// (legacy) and `oc_sk_` (new console) prefixes. -export function isLegacyOpenCodeGoApiKey(value: string): boolean { - const trimmed = value.trim() - return /^(?:oc_)?sk[-_][A-Za-z0-9._-]+$/.test(trimmed) -} - export function isLegacySshPtyOwnerLease(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) } diff --git a/src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.test.ts b/src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.test.ts new file mode 100644 index 000000000000..a5e94937b516 --- /dev/null +++ b/src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.test.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' + +const secretStore = vi.hoisted(() => ({ + isEncryptionAvailable: vi.fn(() => true), + encryptString: vi.fn((value: string) => Buffer.from(`enc:${value}`)), + decryptString: vi.fn((value: Buffer) => value.toString().slice(4)) +})) + +vi.mock('../../../shared/secret-store', () => ({ getSecretStore: () => secretStore })) + +const { ProtectedSecretPersistence } = await import('../../protected-secret-persistence') +const { + LEGACY_OPENCODE_GO_API_KEY_SLOT, + migrateLegacyOpenCodeGoApiKey, + retainLegacyOpenCodeGoApiKey +} = await import('./legacy-opencode-go-api-key-migration') + +const SEALED = Buffer.from('enc:fake-legacy-key').toString('base64') + +function memoryStore(initial: string | null = null): { + has: () => boolean + read: () => string | null + save: Mock<(key: string) => void> + value: () => string | null +} { + let saved = initial + return { + has: () => saved !== null, + read: () => saved, + save: vi.fn((key: string) => { + saved = key + }), + value: () => saved + } +} + +function parked(value: unknown = SEALED): InstanceType { + const secrets = new ProtectedSecretPersistence() + retainLegacyOpenCodeGoApiKey({ opencodeGoApiKey: value }, secrets) + return secrets +} + +beforeEach(() => { + vi.clearAllMocks() + secretStore.isEncryptionAvailable.mockReturnValue(true) + secretStore.decryptString.mockImplementation((value: Buffer) => value.toString().slice(4)) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('retainLegacyOpenCodeGoApiKey', () => { + it('drops the field from settings and parks the ciphertext without decrypting it', () => { + const settings: Record = { opencodeGoApiKey: SEALED, other: 1 } + const secrets = new ProtectedSecretPersistence() + + retainLegacyOpenCodeGoApiKey(settings, secrets) + + expect(settings).toEqual({ other: 1 }) + expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBe(SEALED) + expect(secretStore.decryptString).not.toHaveBeenCalled() + }) + + it('seals a #22551 plaintext key before parking it, and migration still recovers it', () => { + const secrets = parked('sk-fake-plaintext-key') + + const blob = secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT) + expect(blob).toBe(Buffer.from('enc:sk-fake-plaintext-key').toString('base64')) + const store = memoryStore() + expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(true) + expect(store.value()).toBe('sk-fake-plaintext-key') + }) + + it('parks a plaintext key verbatim while encryption is unavailable', () => { + secretStore.isEncryptionAvailable.mockReturnValue(false) + const secrets = parked('sk-fake-plaintext-key') + + expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBe('sk-fake-plaintext-key') + expect(secretStore.encryptString).not.toHaveBeenCalled() + }) + + it('ignores settings without the field and drops malformed values', () => { + const secrets = new ProtectedSecretPersistence() + retainLegacyOpenCodeGoApiKey({ other: 1 }, secrets) + retainLegacyOpenCodeGoApiKey(undefined, secrets) + const malformed: Record = { opencodeGoApiKey: { nested: 'fake' } } + retainLegacyOpenCodeGoApiKey(malformed, secrets) + + expect(malformed).toEqual({}) + expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBeNull() + }) +}) + +describe('migrateLegacyOpenCodeGoApiKey', () => { + it('saves the key into an empty store, then releases the legacy value', () => { + const secrets = parked() + const store = memoryStore() + + expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(true) + expect(store.value()).toBe('fake-legacy-key') + expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBeNull() + }) + + it('is idempotent: a second run has nothing left to move', () => { + const secrets = parked() + const store = memoryStore() + + migrateLegacyOpenCodeGoApiKey(secrets, store) + expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(false) + expect(store.save).toHaveBeenCalledOnce() + }) + + it('never overwrites a readable key already saved in the store, and releases the legacy value', () => { + const secrets = parked() + const store = memoryStore('fake-current-key') + + expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(true) + expect(store.save).not.toHaveBeenCalled() + expect(store.value()).toBe('fake-current-key') + expect(secretStore.decryptString).not.toHaveBeenCalled() + expect(console.warn).toHaveBeenCalledWith( + '[opencode-go] Kept the existing saved API key and dropped the one from settings.' + ) + }) + + it('keeps the legacy value when the existing store file cannot be read by this build', () => { + const secrets = parked() + const store = { + ...memoryStore('fake-other-build-key'), + read: () => { + throw new Error('OpenCode Go API key could not be decrypted') + } + } + + expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(false) + expect(store.save).not.toHaveBeenCalled() + expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBe(SEALED) + }) + + it('keeps the ciphertext while safeStorage is unavailable so a later startup retries', () => { + const secrets = parked() + const store = memoryStore() + secretStore.isEncryptionAvailable.mockReturnValue(false) + + expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(false) + expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBe(SEALED) + + secretStore.isEncryptionAvailable.mockReturnValue(true) + expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(true) + expect(store.value()).toBe('fake-legacy-key') + }) + + it('keeps the ciphertext after a definitive decrypt failure', () => { + const secrets = parked() + const store = memoryStore() + secretStore.decryptString.mockImplementation(() => { + throw new Error('bad key') + }) + + expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(false) + expect(store.save).not.toHaveBeenCalled() + expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBe(SEALED) + }) + + it('moves a plaintext key #22551 accepted when it failed to decrypt', () => { + secretStore.isEncryptionAvailable.mockReturnValue(false) + const secrets = parked('sk-fake-plaintext-key') + secretStore.isEncryptionAvailable.mockReturnValue(true) + const store = memoryStore() + secretStore.decryptString.mockImplementation(() => { + throw new Error('not ciphertext') + }) + + expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(true) + expect(store.value()).toBe('sk-fake-plaintext-key') + }) + + it('keeps the ciphertext when the store cannot save, without logging the key', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const secrets = parked() + const store = { + has: () => false, + read: () => null, + save: () => { + throw new Error('disk full') + } + } + + expect(migrateLegacyOpenCodeGoApiKey(secrets, store)).toBe(false) + expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBe(SEALED) + expect(JSON.stringify(warn.mock.calls)).not.toContain('fake-legacy-key') + + const working = memoryStore() + expect(migrateLegacyOpenCodeGoApiKey(secrets, working)).toBe(true) + expect(working.value()).toBe('fake-legacy-key') + expect(secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT)).toBeNull() + }) +}) diff --git a/src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.ts b/src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.ts new file mode 100644 index 000000000000..2571f6af024c --- /dev/null +++ b/src/main/persistence/loading-store/legacy-opencode-go-api-key-migration.ts @@ -0,0 +1,100 @@ +import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' + +// Why a private slot: #22551 sealed the key under this name; only this migration still reads it. +export const LEGACY_OPENCODE_GO_API_KEY_SLOT = 'settings.opencodeGoApiKey' + +type LegacyOpenCodeGoApiKeySecrets = Pick< + ProtectedSecretPersistence, + 'decryptWithStatus' | 'removeRetainedBlob' | 'retainSealed' | 'sealedBlob' +> + +export type OpenCodeGoApiKeyTarget = { + has: () => boolean + /** Throws when the saved key cannot be read or decrypted by this build. */ + read: () => string | null + save: (key: string) => void +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +// #22551 accepted a plaintext `sk-`/`oc_sk_` value that failed to decrypt; keep honoring it. +function isLegacyPlaintextOpenCodeGoApiKey(value: string): boolean { + return /^(?:oc_)?sk[-_][A-Za-z0-9._-]+$/.test(value.trim()) +} + +/** + * Moves the #22551 settings value out of settings and parks its ciphertext undecrypted, so every + * Store consumer (orcad included) writes it back until the desktop migration gives it a new home. + */ +export function retainLegacyOpenCodeGoApiKey( + settings: unknown, + secrets: Pick +): void { + if (!isRecord(settings) || !('opencodeGoApiKey' in settings)) { + return + } + const sealed = settings.opencodeGoApiKey + // Why: in-memory settings reach the renderer and remote settings.get; the key must not ride along. + delete settings.opencodeGoApiKey + if (typeof sealed !== 'string' || !sealed) { + return + } + if (!isLegacyPlaintextOpenCodeGoApiKey(sealed)) { + secrets.retainSealed(LEGACY_OPENCODE_GO_API_KEY_SLOT, sealed) + return + } + // Why: orcad-only profiles never migrate, so seal it now; without encryption #22551 kept it plaintext too. + const encrypted = secrets.encrypt(LEGACY_OPENCODE_GO_API_KEY_SLOT, sealed) + secrets.retainSealed( + LEGACY_OPENCODE_GO_API_KEY_SLOT, + encrypted.degraded ? sealed : encrypted.blob + ) +} + +/** + * Saves the parked key into the main-owned store; a key already saved there wins. + * @returns True once the legacy value is released and may be dropped from disk. + */ +export function migrateLegacyOpenCodeGoApiKey( + secrets: LegacyOpenCodeGoApiKeySecrets, + target: OpenCodeGoApiKeyTarget +): boolean { + const sealed = secrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT) + if (!sealed) { + return false + } + try { + // Why: a file another app identity sealed is unreadable here; read throws and keeps the legacy key. + const existing = target.has() ? target.read() : null + if (existing === null) { + const decrypted = secrets.decryptWithStatus( + LEGACY_OPENCODE_GO_API_KEY_SLOT, + sealed, + isLegacyPlaintextOpenCodeGoApiKey + ) + // Why keep a definitive failure too: a restored keychain can still open the inert ciphertext, while dropping it is irreversible. + if (decrypted.status === 'unavailable' || !decrypted.plaintext) { + secrets.retainSealed(LEGACY_OPENCODE_GO_API_KEY_SLOT, sealed) + return false + } + const key = decrypted.plaintext.trim() + if (key) { + target.save(key) + } + } else { + // Why: the store is machine-wide, so another profile's key can win; leave a trace of the drop. + console.warn( + '[opencode-go] Kept the existing saved API key and dropped the one from settings.' + ) + } + } catch { + // Why: startup must not fail on a disk or keychain error; the next startup retries. + secrets.retainSealed(LEGACY_OPENCODE_GO_API_KEY_SLOT, sealed) + console.warn('[opencode-go] Could not migrate the saved API key out of settings.') + return false + } + secrets.removeRetainedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT) + return true +} diff --git a/src/main/persistence/loading-store/loaded-state-parsing.ts b/src/main/persistence/loading-store/loaded-state-parsing.ts index b93dc2515001..cf699cd77246 100644 --- a/src/main/persistence/loading-store/loaded-state-parsing.ts +++ b/src/main/persistence/loading-store/loaded-state-parsing.ts @@ -14,11 +14,11 @@ import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence' import { - isLegacyOpenCodeGoApiKey, isLegacyOpenCodeSessionCookie, isLegacySshPtyOwnerLease } from '../leasing-ssh-ptys/secret-validation' import { readGithubCacheSnapshot } from './user-data-path' +import { retainLegacyOpenCodeGoApiKey } from './legacy-opencode-go-api-key-migration' import { gcStaleWorktreeMeta, normalizeWorktreeLinkedItemMetadata @@ -113,13 +113,7 @@ export class LoadedStateParsingOperations { isLegacyOpenCodeSessionCookie ) } - if (parsed.settings?.opencodeGoApiKey) { - parsed.settings.opencodeGoApiKey = this.runtime.protectedSecrets.decrypt( - PROTECTED_SECRET_SLOT.opencodeGoApiKey, - parsed.settings.opencodeGoApiKey, - isLegacyOpenCodeGoApiKey - ) - } + retainLegacyOpenCodeGoApiKey(parsed.settings, this.runtime.protectedSecrets) if (parsed.settings?.httpProxyUrl) { const decryptedProxy = this.runtime.protectedSecrets.decryptWithStatus( PROTECTED_SECRET_SLOT.httpProxyUrl, diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts index 40d40818343c..c4346545ef4c 100644 --- a/src/main/persistence/loading-store/state-serialization-secret-handling.ts +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -12,6 +12,7 @@ import { type ProtectedSecretRetentionUpdate } from '../../protected-secret-persistence' import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations' +import { LEGACY_OPENCODE_GO_API_KEY_SLOT } from './legacy-opencode-go-api-key-migration' import { omitDefaultWorktreeMetaFieldsInMap } from '../../../shared/worktree/meta-persisted-defaults' import { projectWorktreeMetaByIdentityOntoLocators } from './worktree-meta-alias-projection' import { withoutRedundantPartitionGlobals } from '../../../shared/workspace-session-host-field-ownership' @@ -260,10 +261,9 @@ export class StateSerializationSecretHandlingOperations { PROTECTED_SECRET_SLOT.opencodeSessionCookie, this.runtime.state.settings.opencodeSessionCookie ), - opencodeGoApiKey: encrypt( - PROTECTED_SECRET_SLOT.opencodeGoApiKey, - this.runtime.state.settings.opencodeGoApiKey ?? '' - ), + ...(this.runtime.protectedSecrets.sealedBlob(LEGACY_OPENCODE_GO_API_KEY_SLOT) + ? { opencodeGoApiKey: encrypt(LEGACY_OPENCODE_GO_API_KEY_SLOT, '') } + : {}), httpProxyUrl: encrypt( PROTECTED_SECRET_SLOT.httpProxyUrl, this.runtime.state.settings.httpProxyUrl ?? '' diff --git a/src/main/persistence/loading-store/state-write-round-trip.test.ts b/src/main/persistence/loading-store/state-write-round-trip.test.ts index 1d543fb5b5fb..3550c3b53125 100644 --- a/src/main/persistence/loading-store/state-write-round-trip.test.ts +++ b/src/main/persistence/loading-store/state-write-round-trip.test.ts @@ -1,7 +1,8 @@ import { closeTestStores, createSqliteTestStore, - readPersistedStateJson + readPersistedStateJson, + writePersistedStateJson } from '../../persistence-test-harness' /** * The write path now hands the file a Buffer it built in one pass instead of a string it rebuilt @@ -10,6 +11,7 @@ import { * against (a mis-sliced segment, a re-encoded payload, a dropped sentinel) is invisible until * something reads the bytes back. */ +import { getSecretStore } from '../../../shared/secret-store' import { mkdtempSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -77,6 +79,68 @@ function session(activeTabId: string): WorkspaceSessionState { } describe('persisted state survives a save/load round trip', () => { + it('keeps a #22551 settings-slot OpenCode Go key on disk until its new owner has it', () => { + const dataFile = join( + realpathSync(mkdtempSync(join(tmpdir(), 'orca-legacy-opencode-go-key-'))), + 'state.json' + ) + const first = openStore(dataFile) + first.updateSettings({ opencodeWorkspaceId: 'wrk_test' }) + first.flush() + const persisted = JSON.parse(readPersistedStateJson(dataFile)) + // Sealed exactly as #22551's protected-secret slot wrote it. + const sealed = getSecretStore().encryptString('fake-legacy-key').toString('base64') + persisted.settings.opencodeGoApiKey = sealed + writePersistedStateJson(dataFile, JSON.stringify(persisted)) + const onDiskKey = (): unknown => + JSON.parse(readPersistedStateJson(dataFile)).settings.opencodeGoApiKey + + // orcad-style consumer: loads and flushes the profile but never runs the migration. + const daemon = createSqliteTestStore(Store, { dataFile }) + stores.push(daemon) + expect(daemon.getSettings()).not.toHaveProperty('opencodeGoApiKey') + daemon.updateSettings({ opencodeWorkspaceId: 'wrk_daemon' }) + daemon.flush() + expect(onDiskKey()).toBe(sealed) + expect(readPersistedStateJson(dataFile)).not.toContain('fake-legacy-key') + + const loaded = openStore(dataFile) + expect(loaded.getSettings().opencodeWorkspaceId).toBe('wrk_daemon') + expect(loaded.getSettings()).not.toHaveProperty('opencodeGoApiKey') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + loaded.migrateLegacyOpenCodeGoApiKey({ + has: () => false, + read: () => null, + save: () => { + throw new Error('disk full') + } + }) + loaded.flush() + expect(onDiskKey()).toBe(sealed) + + // Why: an older paired client can still send the retired field; it must not be stored. + const updates = { opencodeGoApiKey: 'fake-remote-key', opencodeWorkspaceId: 'wrk_next' } + expect(loaded.updateSettings(updates)).not.toHaveProperty('opencodeGoApiKey') + loaded.flush() + expect(onDiskKey()).toBe(sealed) + + const saved: string[] = [] + loaded.migrateLegacyOpenCodeGoApiKey({ + has: () => saved.length > 0, + read: () => saved[0] ?? null, + save: (key) => saved.push(key) + }) + loaded.migrateLegacyOpenCodeGoApiKey({ + has: () => saved.length > 0, + read: () => saved[0] ?? null, + save: (key) => saved.push(key) + }) + expect(saved).toEqual(['fake-legacy-key']) + loaded.flush() + expect(readPersistedStateJson(dataFile)).not.toContain('opencodeGoApiKey') + expect(openStore(dataFile).getSettings()).not.toHaveProperty('opencodeGoApiKey') + }) + it('reloads settings, secrets and both session partitions unchanged', () => { const dataFile = join( realpathSync(mkdtempSync(join(tmpdir(), 'orca-store-round-trip-'))), diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index d60f1a7e3e1e..20528b93bac7 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -17,6 +17,10 @@ import { } from './store-domain-composition' import type { PersistedState } from '../../../shared/persisted-state-types' import { scheduleSave } from './write-scheduling' +import { + migrateLegacyOpenCodeGoApiKey, + type OpenCodeGoApiKeyTarget +} from './legacy-opencode-go-api-key-migration' import { enqueuePrimaryStateOperation, writeToDiskAsync } from './primary-state-writes' import type { ProfileStateDatabaseQuarantine } from '../profile-state/profile-state-database-quarantine' import { writeVersionedProfileStateExport } from '../profile-state/legacy-json/profile-state-versioned-export' @@ -151,6 +155,13 @@ export class Store { } } + /** Moves the #22551 settings-slot OpenCode Go key into `target`; it stays on disk until that succeeds. */ + migrateLegacyOpenCodeGoApiKey(target: OpenCodeGoApiKeyTarget): void { + if (migrateLegacyOpenCodeGoApiKey(this.runtime.protectedSecrets, target)) { + scheduleSave(this.domains.scheduling) + } + } + getProfileStorageDirectory(): string { return dirname(this.runtime.dataFile) } diff --git a/src/main/protected-secret-persistence.ts b/src/main/protected-secret-persistence.ts index c72af0966197..5a9c6ed3bc50 100644 --- a/src/main/protected-secret-persistence.ts +++ b/src/main/protected-secret-persistence.ts @@ -2,7 +2,6 @@ import { getSecretStore } from '../shared/secret-store' export const PROTECTED_SECRET_SLOT = { opencodeSessionCookie: 'settings.opencodeSessionCookie', - opencodeGoApiKey: 'settings.opencodeGoApiKey', httpProxyUrl: 'settings.httpProxyUrl', browserKagiSessionLink: 'ui.browserKagiSessionLink' } as const @@ -49,6 +48,17 @@ export class ProtectedSecretPersistence { this.pendingEncryption.delete(slot) } + /** Parks ciphertext without allowing an earlier pending write to replace it. */ + retainSealed(slot: string, blob: string): void { + this.removeRetainedBlob(slot) + this.retainedBlobs.set(slot, blob) + this.sealedSlots.add(slot) + } + + sealedBlob(slot: string): string | null { + return this.sealedSlots.has(slot) ? (this.retainedBlobs.get(slot) ?? null) : null + } + isSealed(slot: string, value: string): boolean { return this.sealedSlots.has(slot) && this.retainedBlobs.get(slot) === value } diff --git a/src/main/rate-limits/opencode-go-api-key-source.test.ts b/src/main/rate-limits/opencode-go-api-key-source.test.ts index aadbf42a2ab1..b463cea5b9ff 100644 --- a/src/main/rate-limits/opencode-go-api-key-source.test.ts +++ b/src/main/rate-limits/opencode-go-api-key-source.test.ts @@ -107,7 +107,7 @@ describe('resolveOpenCodeGoApiKey', () => { }) }) - it('falls back to the key OpenCode 1.x saved on /connect', async () => { + it('falls back to the key OpenCode 1.x saved on /connect when the table is empty', async () => { writeAuthFile({ anthropic: { type: 'oauth', refresh: 'r', access: 'a', expires: 1 }, 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } @@ -139,6 +139,34 @@ describe('resolveOpenCodeGoApiKey', () => { }) }) + it('prefers the credential table over a stale auth.json, since OpenCode 2 stops writing the file', async () => { + process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) + const { path } = writeCredentialDatabase([ + { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 } + ]) + process.env.OPENCODE_DB = path + + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ + status: 'found', + key: DATABASE_KEY, + tier: 'opencode-credential-database' + }) + }) + + it('keeps the settings override above the credential table', async () => { + const { path } = writeCredentialDatabase([ + { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 } + ]) + process.env.OPENCODE_DB = path + + await expect(resolveOpenCodeGoApiKey({ settingsOverride: SETTINGS_KEY })).resolves.toEqual({ + status: 'found', + key: SETTINGS_KEY, + tier: 'settings' + }) + }) + it('reports missing when no tier holds a key', async () => { writeAuthFile({ 'opencode-go': { type: 'oauth', refresh: 'r', access: 'a', expires: 1 } }) diff --git a/src/main/rate-limits/opencode-go-api-key-source.ts b/src/main/rate-limits/opencode-go-api-key-source.ts index 545bd058d8ec..eecfeec1ea7a 100644 --- a/src/main/rate-limits/opencode-go-api-key-source.ts +++ b/src/main/rate-limits/opencode-go-api-key-source.ts @@ -160,13 +160,13 @@ export async function readOpenCodeCredentialDatabaseGoKey(): Promise ({ + fetchClaudeRateLimits: vi.fn(), + fetchManagedAccountUsage: vi.fn() +})) + +vi.mock('./codex-fetcher', () => ({ + consumeCodexRateLimitResetCredit: vi.fn(), + fetchCodexRateLimits: vi.fn() +})) + +vi.mock('./gemini-usage-fetcher', () => ({ + fetchGeminiRateLimits: vi.fn() +})) + +vi.mock('./kimi-fetcher', () => ({ + fetchKimiRateLimits: vi.fn() +})) + +vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() })) +vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ fetchAntigravityRateLimits: vi.fn() })) + +vi.mock('./opencode-go-usage-source-selection', () => ({ + fetchOpenCodeGoUsage: vi.fn() +})) + +vi.mock('./minimax/minimax-fetcher', () => ({ + fetchMiniMaxRateLimits: vi.fn() +})) + +vi.mock('./grok-fetcher', () => ({ + fetchGrokRateLimits: vi.fn() +})) + +vi.mock('./grok-auth', () => ({ + readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) +})) + +vi.mock('../minimax/minimax-cookie-store', () => ({ + hasMiniMaxSessionCookie: vi.fn(() => false) +})) + +const DECRYPT_ERROR = + 'OpenCode Go API key could not be decrypted. Re-enter or clear the key in Settings.' + +function serviceWithCookie(apiKeyResolver: () => string | null): RateLimitService { + const service = new RateLimitService() + service.setOpenCodeGoConfigResolver( + () => ({ sessionCookie: 'auth=fake-cookie', workspaceIdOverride: '' }), + apiKeyResolver + ) + return service +} + +function undecryptableKey(): string | null { + throw new Error('OpenCode Go API key could not be decrypted') +} + +describe('OpenCode Go credential state', () => { + beforeEach(() => { + resetRateLimitProviderMocks() + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 0)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 0)) + }) + + it('treats an undecryptable saved key as absent so the cookie still produces usage', async () => { + const service = serviceWithCookie(undecryptableKey) + vi.mocked(fetchOpenCodeGoUsage).mockResolvedValueOnce(okProvider('opencode-go', 40)) + + await service.refresh() + + expect(fetchOpenCodeGoUsage).toHaveBeenCalledWith( + expect.objectContaining({ settingsApiKey: '', cookie: 'auth=fake-cookie' }) + ) + const state = service.getState() + expect(state.opencodeGo?.status).toBe('ok') + expect(state.opencodeGo?.error).toBeNull() + }) + + it('shows the decrypt error only when no other source produced usage', async () => { + const service = serviceWithCookie(undecryptableKey) + vi.mocked(fetchOpenCodeGoUsage).mockImplementationOnce(async (input) => { + input.onApiKeyResolved?.({ status: 'missing' }) + return unavailableProvider('opencode-go', 'No OpenCode Go API key or session cookie') + }) + + await service.refresh() + + const state = service.getState() + expect(state.opencodeGo?.status).toBe('error') + expect(state.opencodeGo?.error).toBe(DECRYPT_ERROR) + // Why: the bar must stay visible to surface how to fix the saved key. + expect(state.opencodeGoApiKeyConfigured).toBe(true) + }) + + it('skips a transiently unreadable saved key without blaming it, keeping the bar visible', async () => { + const service = serviceWithCookie(() => { + throw new ApiKeyFileUnreadableError('OpenCode Go API key file could not be read') + }) + vi.mocked(fetchOpenCodeGoUsage).mockImplementationOnce(async (input) => { + input.onApiKeyResolved?.({ status: 'missing' }) + return unavailableProvider('opencode-go', 'No OpenCode Go API key or session cookie') + }) + + await service.refresh() + + expect(fetchOpenCodeGoUsage).toHaveBeenCalledWith( + expect.objectContaining({ settingsApiKey: '', cookie: 'auth=fake-cookie' }) + ) + const state = service.getState() + expect(state.opencodeGo?.status).toBe('unavailable') + expect(state.opencodeGo?.error).not.toBe(DECRYPT_ERROR) + expect(state.opencodeGoApiKeyConfigured).toBe(true) + }) + + it('keeps a real cookie error instead of the decrypt error', async () => { + const service = serviceWithCookie(undecryptableKey) + vi.mocked(fetchOpenCodeGoUsage).mockResolvedValueOnce({ + ...unavailableProvider('opencode-go', 'OpenCode session cookie expired'), + status: 'error' + }) + + await service.refresh() + + const state = service.getState() + expect(state.opencodeGo?.status).toBe('error') + expect(state.opencodeGo?.error).toBe('OpenCode session cookie expired') + }) + + it('keeps the chip visible across a cookie change while a key source exists', async () => { + const service = serviceWithCookie(() => null) + vi.mocked(fetchOpenCodeGoUsage).mockImplementation(async (input) => { + input.onApiKeyResolved?.({ status: 'found', key: 'fake-env-key', tier: 'environment' }) + return okProvider('opencode-go', 10) + }) + await service.refresh() + expect(service.getState().opencodeGoApiKeyConfigured).toBe(true) + + service.invalidateOpenCodeGoCredentialState() + + expect(service.getState().opencodeGo?.status).toBe('fetching') + expect(service.getState().opencodeGoApiKeyConfigured).toBe(true) + + service.invalidateOpenCodeGoCredentialState({ apiKeyCleared: true }) + expect(service.getState().opencodeGoApiKeyConfigured).toBe(false) + }) + + it('passes the saved key to the fetch as the settings override', async () => { + const service = serviceWithCookie(() => 'fake-saved-key') + + await service.refresh() + + expect(fetchOpenCodeGoUsage).toHaveBeenCalledWith( + expect.objectContaining({ settingsApiKey: 'fake-saved-key' }) + ) + }) + + it('drops an in-flight result fetched with a credential that was since replaced', async () => { + const service = serviceWithCookie(() => 'fake-old-key') + const pending = deferred() + let resolvedWithOldKey: (() => void) | undefined + vi.mocked(fetchOpenCodeGoUsage).mockImplementationOnce((input) => { + resolvedWithOldKey = () => + input.onApiKeyResolved?.({ status: 'found', key: 'fake-old-key', tier: 'settings' }) + return pending.promise + }) + + const refresh = service.refresh() + await flushMicrotasks() + service.invalidateOpenCodeGoCredentialState() + resolvedWithOldKey?.() + pending.resolve(okProvider('opencode-go', 90)) + await refresh + + const state = service.getState() + expect(state.opencodeGo?.session?.usedPercent).not.toBe(90) + expect(state.opencodeGoApiKeyConfigured).toBe(false) + }) +}) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index e0a1d9c9b042..2cef94ba2b3e 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -431,8 +431,7 @@ describe('RateLimitService', () => { const service = new RateLimitService() service.setOpenCodeGoConfigResolver(() => ({ sessionCookie: 'session=abc123', - workspaceIdOverride: '', - apiKey: '' + workspaceIdOverride: '' })) const networkProxySettings = { httpProxyUrl: 'http://proxy.example:8080', @@ -562,8 +561,7 @@ describe('RateLimitService', () => { const service = new RateLimitService() service.setOpenCodeGoConfigResolver(() => ({ sessionCookie: '', - workspaceIdOverride: '', - apiKey: '' + workspaceIdOverride: '' })) vi.mocked(fetchClaudeRateLimits).mockRejectedValueOnce(new Error('claude down')) @@ -587,8 +585,7 @@ describe('RateLimitService', () => { let cookie = 'session=valid' service.setOpenCodeGoConfigResolver(() => ({ sessionCookie: cookie, - workspaceIdOverride: '', - apiKey: '' + workspaceIdOverride: '' })) // 1. Success fetch @@ -624,8 +621,7 @@ describe('RateLimitService', () => { let workspaceId = 'wrk_A' service.setOpenCodeGoConfigResolver(() => ({ sessionCookie: 'session=valid', - workspaceIdOverride: workspaceId, - apiKey: '' + workspaceIdOverride: workspaceId })) // 1. Success fetch for Workspace A diff --git a/src/main/rate-limits/service/service-account-refresh.ts b/src/main/rate-limits/service/service-account-refresh.ts index ed8578e36940..22d95d974d42 100644 --- a/src/main/rate-limits/service/service-account-refresh.ts +++ b/src/main/rate-limits/service/service-account-refresh.ts @@ -29,6 +29,19 @@ export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceIna return this.getState() } + invalidateOpenCodeGoCredentialState(options: { apiKeyCleared?: boolean } = {}): void { + this.opencodeFetchGeneration += 1 + // Why: a key from env, OpenCode's DB or auth.json survives a cookie change; only clearing the saved key hides the chip. + if (options.apiKeyCleared) { + this.openCodeGoApiKeyConfigured = false + } + // Why: a credential change must discard the snapshot and any result still in flight. + this.updateState({ + ...this.state, + opencodeGo: this.withFetchingStatus(null, 'opencode-go') + }) + } + invalidateMiniMaxCredentialState(): void { this.minimaxFetchGeneration += 1 // Why: saving/forgetting the cookie can race an in-flight fetch; clear the visible snapshot before any old-cookie result returns. diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts index 2a788b1cf935..f0322ea476f5 100644 --- a/src/main/rate-limits/service/service-configuration.ts +++ b/src/main/rate-limits/service/service-configuration.ts @@ -40,8 +40,12 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco this.claudeFetchTarget = normalizeClaudeAccountSelectionTarget(target) } - setOpenCodeGoConfigResolver(resolver: () => OpenCodeGoRateLimitConfig): void { + setOpenCodeGoConfigResolver( + resolver: () => OpenCodeGoRateLimitConfig, + apiKeyResolver?: () => string | null + ): void { this.openCodeGoConfigResolver = resolver + this.openCodeGoApiKeyResolver = apiKeyResolver ?? null } setMiniMaxConfigResolver(resolver: () => MiniMaxRateLimitConfig): void { diff --git a/src/main/rate-limits/service/service-fetch-targets.ts b/src/main/rate-limits/service/service-fetch-targets.ts index a24322a54712..798c0a7917b2 100644 --- a/src/main/rate-limits/service/service-fetch-targets.ts +++ b/src/main/rate-limits/service/service-fetch-targets.ts @@ -6,6 +6,7 @@ import { type ClaudeRuntimeAuthPreparation, type CodexAccountSelectionTarget, type MiniMaxResolvedConfig, + type OpenCodeGoResolvedConfig, type NormalizedCodexAccountSelectionTarget, type NormalizedClaudeAccountSelectionTarget, type ProviderRateLimits, @@ -13,6 +14,7 @@ import { toErrorMessage } from './service-types' import type { CodexRateLimitResetOutcome } from '../../../shared/rate-limit-types' +import { ApiKeyFileUnreadableError } from '../../credentials/api-key-file-unreadable-error' const CODEX_RESET_REFRESH_RETRIES = 3 const CODEX_RESET_REFRESH_DELAY_MS = 250 @@ -187,6 +189,34 @@ export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResul return process.platform !== 'win32' } + protected resolveOpenCodeGoConfig(): OpenCodeGoResolvedConfig { + const config = this.openCodeGoConfigResolver?.() ?? { + sessionCookie: '', + workspaceIdOverride: '' + } + try { + return { + ...config, + apiKey: this.openCodeGoApiKeyResolver?.() ?? '', + apiKeyError: null, + apiKeyReadSkipped: false + } + } catch (error) { + // Why: a transient read failure says nothing about the key, so skip it this cycle without blaming it. + if (error instanceof ApiKeyFileUnreadableError) { + return { ...config, apiKey: '', apiKeyError: null, apiKeyReadSkipped: true } + } + // Why: an unreadable saved key is treated as absent so the cookie and OpenCode's own key still run. + return { + ...config, + apiKey: '', + apiKeyError: + 'OpenCode Go API key could not be decrypted. Re-enter or clear the key in Settings.', + apiKeyReadSkipped: false + } + } + } + protected resolveMiniMaxConfig(): MiniMaxResolvedConfig { try { return { diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index 5395ace5fbff..bd6b6d69e6f5 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -80,10 +80,12 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ ? null : this.getCodexProvenance(codexTarget, codexHomePath) const codexGeneration = this.codexFetchGeneration - const openCodeGoConfig = this.openCodeGoConfigResolver?.() - const cookie = openCodeGoConfig?.sessionCookie ?? '' - const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? '' - const openCodeGoApiKey = openCodeGoConfig?.apiKey ?? '' + const openCodeGoConfig = this.resolveOpenCodeGoConfig() + const cookie = openCodeGoConfig.sessionCookie + const workspaceIdOverride = openCodeGoConfig.workspaceIdOverride + const openCodeGoApiKey = openCodeGoConfig.apiKey + const openCodeGoApiKeyError = openCodeGoConfig.apiKeyError + const openCodeGoApiKeyReadSkipped = openCodeGoConfig.apiKeyReadSkipped const miniMaxConfigResult = this.resolveMiniMaxConfig() const miniMaxCookie = miniMaxConfigResult.config.sessionCookie const miniMaxGroupId = miniMaxConfigResult.config.groupId @@ -100,7 +102,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ const apiKeyFingerprint = openCodeGoApiKey ? createHash('sha256').update(openCodeGoApiKey).digest('hex') : '' - const currentConfigHash = `${cookie}|${workspaceIdOverride}|${apiKeyFingerprint}` + const currentConfigHash = `${cookie}|${workspaceIdOverride}|${apiKeyFingerprint}|${openCodeGoApiKeyError ?? ''}` const opencodeConfigChanged = currentConfigHash !== this.lastOpencodeConfigHash if (opencodeConfigChanged) { this.lastOpencodeConfigHash = currentConfigHash @@ -201,7 +203,15 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ // Why here: the key can also come from the environment or OpenCode's // own store, so presence is only known once the fetch resolves it. onApiKeyResolved: (resolution) => { - this.openCodeGoApiKeyConfigured = resolution.status === 'found' + // Why: a credential change mid-fetch bumps the generation; its stale presence must not win. + if (opencodeGeneration !== this.opencodeFetchGeneration) { + return + } + // An undecryptable or briefly unreadable saved key still counts, so the bar stays up. + this.openCodeGoApiKeyConfigured = + resolution.status === 'found' || + openCodeGoApiKeyError !== null || + openCodeGoApiKeyReadSkipped }, cookie, workspaceIdOverride: workspaceIdOverride || undefined, @@ -223,6 +233,18 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ if (signal.aborted) { return null } + // Why: the decrypt error only replaces a result with no usage and no diagnosis of its own; a real cookie error stays visible. + if ( + openCodeGoApiKeyError && + opencodeGoResult.status === 'fulfilled' && + opencodeGoResult.value.status === 'unavailable' + ) { + opencodeGoResult.value = { + ...opencodeGoResult.value, + error: openCodeGoApiKeyError, + status: 'error' + } + } return { claudeTarget, claudeGeneration, diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index 7af82c6c3135..1ebdb37b7b75 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -99,6 +99,7 @@ export abstract class RateLimitServiceState { wslDistro: null } protected openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null + protected openCodeGoApiKeyResolver: (() => string | null) | null = null protected miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null protected geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null protected inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index a3998c13abe4..5a8210c62afc 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -44,8 +44,15 @@ export type ClaudeAuthPreparationResolver = ( export type OpenCodeGoRateLimitConfig = { sessionCookie: string workspaceIdOverride: string - /** Explicit Orca override; empty means fall back to env and OpenCode's own store. */ +} + +export type OpenCodeGoResolvedConfig = OpenCodeGoRateLimitConfig & { + /** Explicit Orca override; empty means fall back to OpenCode's own store and env. */ apiKey: string + /** Set when the saved override exists but cannot be decrypted. */ + apiKeyError: string | null + /** Set when the saved override exists but a transient read failure skipped it this cycle. */ + apiKeyReadSkipped: boolean } export type MiniMaxRateLimitConfig = { diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index 6abf73d66106..f5fc99866735 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -15,6 +15,11 @@ import { getInitialClaudeRateLimitTarget } from '../rate-limits/claude-rate-limi import { getKimiRuntimeTarget, resolveKimiHome } from '../kimi/kimi-runtime-home' import { readMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' import { readMiniMaxApiKey } from '../minimax/minimax-api-key-store' +import { + hasOpenCodeGoApiKey, + readOpenCodeGoApiKey, + saveOpenCodeGoApiKey +} from '../opencode/opencode-go-api-key-store' import { createAccountRuntimeTargetSettingsSync } from '../rate-limits/account-runtime-target-sync' import { normalizeCodexRuntimeSelection } from '../codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from '../claude-accounts/runtime-selection' @@ -87,6 +92,15 @@ export function initializeMainProcessAccountServices(): void { void syncAccountRuntimeTargets(updates, settings).catch((error) => console.warn('[rate-limits] Failed to apply account runtime target:', error) ) + if ('opencodeSessionCookie' in updates || 'opencodeWorkspaceId' in updates) { + state.rateLimits?.invalidateOpenCodeGoCredentialState() + void state.rateLimits?.refresh().catch((error: unknown) => { + console.warn( + '[rate-limits] Failed to refresh OpenCode Go usage after a settings change:', + error + ) + }) + } // Why: these three pick the MiniMax host and quota bucket, so a stale snapshot from the // previous endpoint would otherwise sit in the status bar until the next poll. if ( @@ -110,14 +124,18 @@ export function initializeMainProcessAccountServices(): void { agentHookServer.setClaudeStatusLineListener((event) => { state.rateLimits!.ingestLiveClaudeRateLimits(event) }) + store.migrateLegacyOpenCodeGoApiKey({ + has: hasOpenCodeGoApiKey, + read: readOpenCodeGoApiKey, + save: saveOpenCodeGoApiKey + }) state.rateLimits.setOpenCodeGoConfigResolver(() => { const settings = store.getSettings() return { sessionCookie: settings.opencodeSessionCookie, - workspaceIdOverride: settings.opencodeWorkspaceId, - apiKey: settings.opencodeGoApiKey + workspaceIdOverride: settings.opencodeWorkspaceId } - }) + }, readOpenCodeGoApiKey) state.rateLimits.setMiniMaxConfigResolver(() => { const settings = store.getSettings() const apiKey = readMiniMaxApiKey() ?? '' diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 220b90c18a6c..5597fed90227 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -141,6 +141,11 @@ export type PreloadApi = { runtime: RuntimeApi['runtime'] runtimeEnvironments: RuntimeApi['runtimeEnvironments'] rateLimits: RateLimitsApi + opencodeGoCredentials: { + getStatus: () => Promise<{ apiKeyConfigured: boolean }> + saveApiKey: (key: string) => Promise<{ apiKeyConfigured: boolean }> + clearApiKey: () => Promise<{ apiKeyConfigured: boolean }> + } minimaxCredentials: MinimaxCredentialsApi grokAccounts: GrokAccountsApi cursorAccounts: CursorAccountsApi diff --git a/src/preload/api/opencode-go-credentials-bridge.ts b/src/preload/api/opencode-go-credentials-bridge.ts new file mode 100644 index 000000000000..35e1a668bc16 --- /dev/null +++ b/src/preload/api/opencode-go-credentials-bridge.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const opencodeGoCredentialsApi = { + getStatus: (): Promise<{ apiKeyConfigured: boolean }> => + ipcRenderer.invoke('opencodeGoCredentials:getStatus'), + saveApiKey: (key: string): Promise<{ apiKeyConfigured: boolean }> => + ipcRenderer.invoke('opencodeGoCredentials:saveApiKey', key), + clearApiKey: (): Promise<{ apiKeyConfigured: boolean }> => + ipcRenderer.invoke('opencodeGoCredentials:clearApiKey') +} satisfies PreloadApi['opencodeGoCredentials'] diff --git a/src/preload/index.ts b/src/preload/index.ts index 42cf236c14db..cb762bbfc775 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -75,6 +75,7 @@ import { nativeChatApi } from './api/native-chat-bridge' import { runtimeApi } from './api/runtime-bridge' import { runtimeEnvironmentsApi } from './api/runtime-environments-bridge' import { rateLimitsApi } from './api/rate-limits-bridge' +import { opencodeGoCredentialsApi } from './api/opencode-go-credentials-bridge' import { minimaxCredentialsApi } from './api/minimax-credentials-bridge' import { grokAccountsApi } from './api/grok-accounts-bridge' import { cursorAccountsApi } from './api/cursor-accounts-bridge' @@ -174,6 +175,7 @@ const api = { runtime: runtimeApi, runtimeEnvironments: runtimeEnvironmentsApi, rateLimits: rateLimitsApi, + opencodeGoCredentials: opencodeGoCredentialsApi, minimaxCredentials: minimaxCredentialsApi, grokAccounts: grokAccountsApi, cursorAccounts: cursorAccountsApi, diff --git a/src/preload/opencode-go-credentials.test.ts b/src/preload/opencode-go-credentials.test.ts new file mode 100644 index 000000000000..e7c5f6d4bea7 --- /dev/null +++ b/src/preload/opencode-go-credentials.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest' +import { opencodeGoCredentialsApi } from './api/opencode-go-credentials-bridge' + +const invoke = vi.hoisted(() => vi.fn(async () => ({ apiKeyConfigured: true }))) +vi.mock('electron', () => ({ ipcRenderer: { invoke } })) + +describe('OpenCode Go credential bridge', () => { + it('exposes only status and write operations', async () => { + expect(await opencodeGoCredentialsApi.getStatus()).toEqual({ apiKeyConfigured: true }) + await opencodeGoCredentialsApi.saveApiKey('fake-key') + await opencodeGoCredentialsApi.clearApiKey() + expect(invoke.mock.calls).toEqual([ + ['opencodeGoCredentials:getStatus'], + ['opencodeGoCredentials:saveApiKey', 'fake-key'], + ['opencodeGoCredentials:clearApiKey'] + ]) + expect(Object.keys(opencodeGoCredentialsApi)).toEqual([ + 'getStatus', + 'saveApiKey', + 'clearApiKey' + ]) + }) +}) diff --git a/src/renderer/src/components/settings/accounts-pane-opencode-credentials.test.ts b/src/renderer/src/components/settings/accounts-pane-opencode-credentials.test.ts new file mode 100644 index 000000000000..63d5665defda --- /dev/null +++ b/src/renderer/src/components/settings/accounts-pane-opencode-credentials.test.ts @@ -0,0 +1,47 @@ +// @vitest-environment happy-dom +import { createElement } from 'react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OpenCodeGoCredentials } from './accounts-pane-opencode-credentials' + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: { settingsSearchQuery: string }) => unknown) => + selector({ settingsSearchQuery: '' }) +})) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('OpenCode Go credentials setting', () => { + it('shows saved status without reading a key, saves a draft, and clears it', async () => { + const getStatus = vi.fn(async () => ({ apiKeyConfigured: true })) + const saveApiKey = vi.fn(async () => ({ apiKeyConfigured: true })) + const clearApiKey = vi.fn(async () => ({ apiKeyConfigured: false })) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + opencodeGoCredentials: { getStatus, saveApiKey, clearApiKey } + } + }) + const onSaved = vi.fn() + render(createElement(OpenCodeGoCredentials, { onSaved })) + await screen.findByText('Saved') + const input = screen.getByLabelText('OpenCode Go API key') + expect(input).toBeInstanceOf(HTMLInputElement) + if (!(input instanceof HTMLInputElement)) { + throw new Error('Missing credential input') + } + expect(input.value).toBe('') + fireEvent.change(input, { target: { value: 'fake-new-key' } }) + fireEvent.click(screen.getByRole('button', { name: 'Replace' })) + await waitFor(() => expect(saveApiKey).toHaveBeenCalledWith('fake-new-key')) + await waitFor(() => expect(input.value).toBe('')) + fireEvent.click(screen.getByRole('button', { name: 'Clear' })) + await screen.findByText('Not saved') + expect(clearApiKey).toHaveBeenCalledOnce() + expect(onSaved).toHaveBeenCalledTimes(2) + expect(screen.queryByRole('button', { name: 'Clear' })).toBeNull() + }) +}) diff --git a/src/renderer/src/components/settings/accounts-pane-opencode-credentials.tsx b/src/renderer/src/components/settings/accounts-pane-opencode-credentials.tsx new file mode 100644 index 000000000000..efddf3c02f3f --- /dev/null +++ b/src/renderer/src/components/settings/accounts-pane-opencode-credentials.tsx @@ -0,0 +1,113 @@ +import { useEffect, useState } from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { SearchableSetting } from './SearchableSetting' + +export function OpenCodeGoCredentials({ onSaved }: { onSaved: () => void }): React.JSX.Element { + const [draft, setDraft] = useState('') + const [configured, setConfigured] = useState(false) + const [busy, setBusy] = useState(false) + useEffect(() => { + let active = true + void window.api.opencodeGoCredentials.getStatus().then( + (status) => { + if (active) { + setConfigured(status.apiKeyConfigured) + } + }, + () => console.error('Failed to load OpenCode Go credential status') + ) + return () => { + active = false + } + }, []) + + const updateCredential = async (clear: boolean): Promise => { + setBusy(true) + try { + const status = clear + ? await window.api.opencodeGoCredentials.clearApiKey() + : await window.api.opencodeGoCredentials.saveApiKey(draft.trim()) + setConfigured(status.apiKeyConfigured) + setDraft('') + onSaved() + } catch { + toast.error(translate('sessionHistory.settings.saveError', 'Could not save. Try again.')) + } finally { + setBusy(false) + } + } + + return ( + +
+ + + {configured + ? translate('auto.components.settings.AccountsPane.73ea15f24b', 'Saved') + : translate('auto.components.settings.AccountsPane.23afe8f226', 'Not saved')} + +
+
+ setDraft(event.target.value)} + placeholder={translate( + 'auto.components.settings.AccountsPane.opencodeGo.apiKey.placeholder', + 'Leave blank to use the key saved by /connect or OPENCODE_API_KEY' + )} + spellCheck={false} + className="flex-1" + /> + + {configured && ( + + )} +
+

+ {translate( + 'auto.components.settings.AccountsPane.opencodeGo.apiKey.help', + 'Used for OpenCode Go usage in the status bar. The session cookie below is only needed for legacy console (OpenCode Black) accounts.' + )} +

+
+ ) +} diff --git a/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx b/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx index 61428c5cd3b3..0ebc60ff471d 100644 --- a/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx +++ b/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx @@ -5,6 +5,7 @@ import { Switch } from '../ui/switch' import { GeminiIcon, OpenCodeGoIcon } from '../status-bar/icons' import { SearchableSetting } from './SearchableSetting' import type { AccountsPaneSectionModel } from './accounts-pane-types' +import { OpenCodeGoCredentials } from './accounts-pane-opencode-credentials' import { DebouncedSettingsTextInput } from './DebouncedSettingsTextInput' export function renderGeminiAccountsSection(model: AccountsPaneSectionModel): React.JSX.Element { @@ -95,58 +96,7 @@ export function renderOpenCodeAccountsSection(model: AccountsPaneSectionModel):

- - -
- recordOpenCodeSettingEdit('apiKey')} - commit={(opencodeGoApiKey) => updateSettings({ opencodeGoApiKey })} - placeholder={translate( - 'auto.components.settings.AccountsPane.opencodeGo.apiKey.placeholder', - 'Leave blank to use the key saved by /connect or OPENCODE_API_KEY' - )} - spellCheck={false} - className="flex-1 text-xs" - /> - {settings.opencodeGoApiKey && ( - - )} -
-

- {translate( - 'auto.components.settings.AccountsPane.opencodeGo.apiKey.help', - 'Used for OpenCode Go usage in the status bar. The session cookie below is only needed for legacy console (OpenCode Black) accounts.' - )} -

-
+ recordOpenCodeSettingEdit('apiKey')} /> ['curs } } +export function createOpenCodeGoCredentialsApi(): PreloadApi['opencodeGoCredentials'] { + const notConfigured = { apiKeyConfigured: false } + return { + getStatus: () => Promise.resolve(notConfigured), + saveApiKey: () => + Promise.reject(new Error('OpenCode Go key storage is only available in the desktop app.')), + clearApiKey: () => Promise.resolve(notConfigured) + } +} + export function createGrokAccountsApi(): NonNullable['grokAccounts']> { const unsigned = { signedIn: false, diff --git a/src/renderer/src/web/web-preload-api-agent-providers.test.ts b/src/renderer/src/web/web-preload-api-agent-providers.test.ts index 8809047fba20..0eea8ecc3c82 100644 --- a/src/renderer/src/web/web-preload-api-agent-providers.test.ts +++ b/src/renderer/src/web/web-preload-api-agent-providers.test.ts @@ -155,6 +155,17 @@ describe('web MiniMax preload API', () => { vi.unstubAllGlobals() }) + it('keeps OpenCode Go credential operations local to the desktop', async () => { + const { api } = await installApi('Linux') + await expect(api.opencodeGoCredentials.getStatus()).resolves.toEqual({ + apiKeyConfigured: false + }) + await expect(api.opencodeGoCredentials.saveApiKey('fake-key')).rejects.toThrow(/desktop app/i) + await expect(api.opencodeGoCredentials.clearApiKey()).resolves.toEqual({ + apiKeyConfigured: false + }) + }) + it('exposes desktop-only MiniMax credential reads as unconfigured and rejects saves', async () => { const { api } = await installApi('Linux') diff --git a/src/renderer/src/web/web-preload-api-composition.test.ts b/src/renderer/src/web/web-preload-api-composition.test.ts index fc119f88258b..5647401810f8 100644 --- a/src/renderer/src/web/web-preload-api-composition.test.ts +++ b/src/renderer/src/web/web-preload-api-composition.test.ts @@ -53,6 +53,7 @@ describe('web preload API composition', () => { 'preflight', 'notifications', 'rateLimits', + 'opencodeGoCredentials', 'minimaxCredentials', 'grokAccounts', 'cursorAccounts', diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 17f77ded803c..8edf8efae4a1 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -6,6 +6,7 @@ import { createCodexAccountsApi, createCursorAccountsApi, createGrokAccountsApi, + createOpenCodeGoCredentialsApi, createMiniMaxCredentialsApi } from './preload-api/web-agent-accounts-api' import { createWebAgentStatusApi } from './preload-api/web-agent-status-api' @@ -106,6 +107,7 @@ function createWebPreloadApi(): Partial { preflight: createPreflightApi(), notifications: createNotificationsApi(), rateLimits: createRateLimitsApi(), + opencodeGoCredentials: createOpenCodeGoCredentialsApi(), minimaxCredentials: createMiniMaxCredentialsApi(), grokAccounts: createGrokAccountsApi(), cursorAccounts: createCursorAccountsApi(), diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 3c6a9d2aa591..2007f3aa916f 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -211,7 +211,6 @@ export function buildDefaultSettings(args: { defaultLinearTeamSelection: null, opencodeSessionCookie: '', opencodeWorkspaceId: '', - opencodeGoApiKey: '', minimaxGroupId: '', minimaxUsageModels: 'general', minimaxEndpoint: 'overseas', diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index c1a9b7d2df12..79bbc7347749 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -392,8 +392,6 @@ export type GlobalSettings = { opencodeSessionCookie: string /** Optional OpenCode Go workspace ID override; when set, skips the workspaces lookup and fetches usage directly. */ opencodeWorkspaceId: string - /** Optional OpenCode Go API key override. Takes precedence over OpenCode's own stored key and OPENCODE_API_KEY. Stored encrypted. */ - opencodeGoApiKey: string /** Optional MiniMax group id. When empty, the usage fetcher extracts minimax_group_id_v2 from the cookie. */ minimaxGroupId: string /** Comma-separated MiniMax model names to show in the status bar usage window. */ From 588117b5cf1dd57e2310c80068e3ddcc9f9044ec Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:41:57 -0700 Subject: [PATCH 09/24] test(opencode): retain legacy key ownership across worker writes --- ...profile-state-delayed-authority-fixture.ts | 13 +++++-- ...file-state-worker-secret-retention.test.ts | 34 +++++++++++++++---- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts index 26709b33e3fa..feaddd284c3c 100644 --- a/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts +++ b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts @@ -113,12 +113,21 @@ afterEach(async () => { vi.restoreAllMocks() }) -export async function fixture() { +export async function fixture(legacyOpenCodeGoApiKey?: string) { const directory = mkdtempSync(join(tmpdir(), 'orca-worker-coordination-')) const path = join(directory, 'profile-state.db') const inner = new ProfileStateSqliteAuthority(path, 'coordination-test') + const initial = buildProfileStateCutoverFixture(directory) inner.writeSerializedState( - Buffer.from(JSON.stringify(buildProfileStateCutoverFixture(directory))) + Buffer.from( + JSON.stringify({ + ...initial, + settings: { + ...initial.settings, + ...(legacyOpenCodeGoApiKey ? { opencodeGoApiKey: legacyOpenCodeGoApiKey } : {}) + } + }) + ) ) const authority = new DelayedAuthority(inner) const store = new Store({ diff --git a/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts b/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts index 4d72ce144c62..648319f59f01 100644 --- a/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts +++ b/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts @@ -27,7 +27,7 @@ beforeEach(() => { }) afterEach(() => setSecretStore(previousSecretStore)) -describe.each(['opencodeSessionCookie', 'opencodeGoApiKey'] as const)( +describe.each(['opencodeSessionCookie'] as const)( 'Store %s retention across worker acknowledgements', (setting) => { it('does not restore ciphertext cleared while its commit acknowledgement was pending', async () => { @@ -94,14 +94,14 @@ describe.each(['opencodeSessionCookie', 'opencodeGoApiKey'] as const)( describe('worker protected settings serialization', () => { it.each(['selective', 'complete'] as const)( - 'encrypts both protected credentials in a %s write to SQLite', + 'seals the cookie and retains the legacy Go key privately in a %s SQLite write', async (mode) => { - const { store, authority, readState } = await fixture() + const { store, authority, readState } = await fixture(ciphertext('legacy-go-key')) const selectiveWrite = vi.spyOn(authority, 'writeSerializedDomains') const completeWrite = vi.spyOn(authority, 'writeCompleteSerializedDomains') const secrets = { opencodeSessionCookie: 'cookie-only-plaintext', - opencodeGoApiKey: 'api-key-only-plaintext' + opencodeGoApiKey: 'retired-write-is-ignored' } store.updateSettings(secrets) if (mode === 'complete') { @@ -113,12 +113,34 @@ describe('worker protected settings serialization', () => { const persisted = readState() expect(persisted.settings).toMatchObject({ opencodeSessionCookie: ciphertext(secrets.opencodeSessionCookie), - opencodeGoApiKey: ciphertext(secrets.opencodeGoApiKey) + opencodeGoApiKey: ciphertext('legacy-go-key') }) for (const plaintext of Object.values(secrets)) { expect(JSON.stringify(persisted)).not.toContain(plaintext) } - expect(store.getSettings()).toMatchObject(secrets) + expect(store.getSettings().opencodeSessionCookie).toBe(secrets.opencodeSessionCookie) + expect(store.getSettings()).not.toHaveProperty('opencodeGoApiKey') } ) }) + +it('does not restore the migrated legacy Go ciphertext from a pending worker acknowledgement', async () => { + const { store, authority, readState } = await fixture(ciphertext('legacy-go-key')) + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const save = vi.fn() + store.migrateLegacyOpenCodeGoApiKey({ has: () => false, read: () => null, save }) + encryptionAvailable = false + gate.finish.resolve() + await write + await store.flushPendingOrThrowAsync() + expect(save).toHaveBeenCalledExactlyOnceWith('legacy-go-key') + expect(readState().settings).not.toHaveProperty('opencodeGoApiKey') + expect(store.getSettings()).not.toHaveProperty('opencodeGoApiKey') + encryptionAvailable = true + store.updateSettings({ theme: 'light' }) + await store.flushPendingOrThrowAsync() + expect(readState().settings).not.toHaveProperty('opencodeGoApiKey') +}) From d1abb6034d8b83c82e417f6c8c72a4daa4b72c36 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 04:05:48 -0700 Subject: [PATCH 10/24] 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 e4f1cbfe8b5d..c824301e5a5f 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 dedd4f8c862a961393977dc3c2547f049391b509 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 04:59:42 -0700 Subject: [PATCH 11/24] test(accounts): provide OpenCode credential API in lifetime fixture --- .../settings/AccountsPane.section-lifetime.test.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx b/src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx index ff46d9637327..fe1059269a70 100644 --- a/src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx @@ -83,6 +83,11 @@ beforeEach(() => { fake.cursorUsage = { updatedAt: 0 } Object.assign(window, { api: { + opencodeGoCredentials: { + getStatus: vi.fn(async () => ({ apiKeyConfigured: false })), + saveApiKey: vi.fn(async () => ({ apiKeyConfigured: true })), + clearApiKey: vi.fn(async () => ({ apiKeyConfigured: false })) + }, minimaxCredentials: { getStatus: vi.fn(async () => ({ cookieConfigured: false, apiKeyConfigured: false })) }, From d10f8b02afc5fd7efcaa6a4e281859edf01cfdb3 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 05:54:17 -0700 Subject: [PATCH 12/24] 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 0f0fdbecedde..181a572a594c 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 8fe5e4b07ab4..38db9a3923bf 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 13/24] 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 8a9114eb9427..a52a9d703604 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 411cb14f2871..d0a3ba8cef16 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 000000000000..ed13aa02de94 --- /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 56697f94badb..5f64779b364d 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 85e5a669626b..be6be25b175e 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 8546b001cf96..51a44e64e26a 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 000000000000..dbe1ed1ad940 --- /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 4549795f6eb0..5393b55ea0d8 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 8bfeca0af8da..313324533a17 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 14/24] 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 9cecb6f12e4a..8b2fe9d5ae7e 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 2fdc394b87ea..19d7cac1b49f 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 91dc2cf48d50..1ab5d61b116b 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 0f1cc0306ec0..0411bdbd1d06 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 15/24] 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 2b76d7a0f1e8..66da6d4b1b71 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 a440b9ccf88a..6dd4b2e23623 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 16/24] 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 a8db7c6762f9..7967ab231e5a 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 6645dae104ee5271139fcc0e9564d9223b74835d Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 06:53:21 -0700 Subject: [PATCH 17/24] fix(opencode): resolve Go keys by execution backend --- .../opencode-database-discovery.ts | 14 +- .../opencode-credential-backend.test.ts | 182 +++++++++++++++++ .../opencode/opencode-credential-backend.ts | 91 +++++++++ .../opencode-go-api-key-source.test.ts | 185 +++++++++++++++++- .../rate-limits/opencode-go-api-key-source.ts | 72 +++++-- ...opencode-go-usage-source-selection.test.ts | 38 ++++ .../opencode-go-usage-source-selection.ts | 14 +- 7 files changed, 568 insertions(+), 28 deletions(-) create mode 100644 src/main/opencode/opencode-credential-backend.test.ts create mode 100644 src/main/opencode/opencode-credential-backend.ts diff --git a/src/main/opencode-usage/opencode-database-discovery.ts b/src/main/opencode-usage/opencode-database-discovery.ts index fb406dc7a796..3525bfbdc84a 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/opencode/opencode-credential-backend.test.ts b/src/main/opencode/opencode-credential-backend.test.ts new file mode 100644 index 000000000000..3bf00336eb10 --- /dev/null +++ b/src/main/opencode/opencode-credential-backend.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' +import { + detectOpenCodeCredentialBackend, + resetOpenCodeCredentialBackendProbes +} from './opencode-credential-backend' + +const files = vi.hoisted(() => ({ realpath: vi.fn(), stat: vi.fn() })) + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) +vi.mock('../ipc/command-path-resolver', () => ({ resolveCommandOnLocalPath: vi.fn() })) +vi.mock('node:fs/promises', () => files) + +describe('OpenCode credential execution backend', () => { + beforeEach(() => { + vi.resetAllMocks() + resetOpenCodeCredentialBackendProbes() + files.realpath.mockImplementation(async (path: string) => path) + files.stat.mockResolvedValue({ dev: 1, ino: 2, size: 3, mtimeMs: 4, ctimeMs: 5 }) + vi.mocked(resolveCommandOnLocalPath).mockResolvedValue('/task/bin/opencode') + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '1.18.30\n', + stderr: '', + timedOut: false + }) + }) + + it.each([ + ['1.18.30\n', 'v1'], + ['opencode v2.0.16\n', 'v2'], + ['2.0.16', 'v2'], + ['opencode v2.0.16-beta.1', 'v2'], + ['3.0.0', null], + ['wrapper 2.0.16', null], + ['', null] + ])('uses the reported backend for %j', async (stdout, backend) => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout, + stderr: '', + timedOut: false + }) + + expect(await detectOpenCodeCredentialBackend()).toBe(backend) + }) + + it('resolves and executes the binary in the caller environment with a bounded probe', async () => { + const environment = { PATH: '/task/bin', XDG_DATA_HOME: '/task/data' } + + await detectOpenCodeCredentialBackend(environment, '/task/workspace') + + expect(resolveCommandOnLocalPath).toHaveBeenCalledExactlyOnceWith('opencode', { + env: environment, + cwd: '/task/workspace' + }) + expect(runProcess).toHaveBeenCalledExactlyOnceWith({ + program: '/task/bin/opencode', + args: ['--version'], + env: environment, + cwd: '/task/workspace', + timeoutMs: 5_000, + maxOutputBytes: 1_024 + }) + }) + + it('probes opencode2 only when the default opencode command is absent', async () => { + vi.mocked(resolveCommandOnLocalPath) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('/task/bin/opencode2') + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: 'opencode v2.0.16', + stderr: '', + timedOut: false + }) + + expect(await detectOpenCodeCredentialBackend()).toBe('v2') + expect(runProcess).toHaveBeenCalledWith( + expect.objectContaining({ program: '/task/bin/opencode2' }) + ) + }) + + it.each([ + { code: 1, timedOut: false }, + { code: 0, timedOut: true }, + { code: 0, timedOut: false, outputTruncated: true } + ])('withholds authority when the installed probe fails: %j', async (failure) => { + vi.mocked(runProcess).mockResolvedValue({ + ...failure, + signal: null, + stdout: 'opencode v2.0.16', + stderr: '' + }) + + expect(await detectOpenCodeCredentialBackend()).toBeNull() + expect(resolveCommandOnLocalPath).toHaveBeenCalledTimes(1) + }) + + it('withholds authority after a spawn error without substituting another CLI', async () => { + vi.mocked(runProcess).mockRejectedValue(new Error('unavailable')) + + expect(await detectOpenCodeCredentialBackend()).toBeNull() + expect(resolveCommandOnLocalPath).toHaveBeenCalledTimes(1) + }) + + it('withholds authority when neither CLI is installed', async () => { + vi.mocked(resolveCommandOnLocalPath).mockResolvedValue(null) + + expect(await detectOpenCodeCredentialBackend()).toBeNull() + expect(runProcess).not.toHaveBeenCalled() + }) + + it('coalesces concurrent calls and reuses a successful binary identity', async () => { + expect( + await Promise.all([ + detectOpenCodeCredentialBackend(), + detectOpenCodeCredentialBackend(), + detectOpenCodeCredentialBackend() + ]) + ).toEqual(['v1', 'v1', 'v1']) + expect(await detectOpenCodeCredentialBackend()).toBe('v1') + expect(runProcess).toHaveBeenCalledTimes(1) + }) + + it('reprobes when the resolved binary is replaced', async () => { + expect(await detectOpenCodeCredentialBackend()).toBe('v1') + files.stat.mockResolvedValue({ dev: 1, ino: 6, size: 3, mtimeMs: 7, ctimeMs: 8 }) + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: 'opencode v2.0.16', + stderr: '', + timedOut: false + }) + + expect(await detectOpenCodeCredentialBackend()).toBe('v2') + expect(runProcess).toHaveBeenCalledTimes(2) + }) + + it('does not reuse a probe across caller environments or working directories', async () => { + await detectOpenCodeCredentialBackend( + { PATH: '/task/bin', XDG_DATA_HOME: '/task/a' }, + '/task/a' + ) + await detectOpenCodeCredentialBackend( + { PATH: '/task/bin', XDG_DATA_HOME: '/task/b' }, + '/task/a' + ) + await detectOpenCodeCredentialBackend( + { PATH: '/task/bin', XDG_DATA_HOME: '/task/b' }, + '/task/b' + ) + + expect(runProcess).toHaveBeenCalledTimes(3) + }) + + it('retries an unknown backend after its short cache expires', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000) + vi.mocked(runProcess).mockRejectedValueOnce(new Error('temporarily unavailable')) + try { + expect(await detectOpenCodeCredentialBackend()).toBeNull() + expect(await detectOpenCodeCredentialBackend()).toBeNull() + now.mockReturnValue(6_001) + expect(await detectOpenCodeCredentialBackend()).toBe('v1') + expect(runProcess).toHaveBeenCalledTimes(2) + } finally { + now.mockRestore() + } + }) + + it('withholds authority when the selected binary identity cannot be read', async () => { + files.stat.mockRejectedValue(new Error('unreadable executable')) + + expect(await detectOpenCodeCredentialBackend()).toBeNull() + expect(runProcess).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/opencode/opencode-credential-backend.ts b/src/main/opencode/opencode-credential-backend.ts new file mode 100644 index 000000000000..69c860dbe48d --- /dev/null +++ b/src/main/opencode/opencode-credential-backend.ts @@ -0,0 +1,91 @@ +import { runProcess } from '../../shared/child-process/run-process' +import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' +import { createHash } from 'node:crypto' +import { realpath, stat } from 'node:fs/promises' + +export type OpenCodeCredentialBackend = 'v1' | 'v2' + +// Native runtime processes own separate execution-host caches. +const probes = new Map< + string, + { result: Promise; expiresAt: number } +>() +const MAX_PROBES = 32 + +export function resetOpenCodeCredentialBackendProbes(): void { + probes.clear() +} + +export async function detectOpenCodeCredentialBackend( + environment: NodeJS.ProcessEnv = process.env, + cwd = process.cwd() +): Promise { + // The execution host's default CLI owns this lookup; table presence proves neither backend. + const program = + (await resolveCommandOnLocalPath('opencode', { env: environment, cwd })) ?? + (await resolveCommandOnLocalPath('opencode2', { env: environment, cwd })) + if (!program) { + return null + } + try { + const binary = await realpath(program) + const identity = await stat(binary) + const environmentDigest = createHash('sha256') + .update(JSON.stringify(Object.entries(environment).sort(([a], [b]) => a.localeCompare(b)))) + .digest('hex') + const key = JSON.stringify([ + binary, + identity.dev, + identity.ino, + identity.size, + identity.mtimeMs, + identity.ctimeMs, + cwd, + environmentDigest + ]) + const cached = probes.get(key) + if (cached && cached.expiresAt > Date.now()) { + return cached.result + } + const result = probeBackend(binary, environment, cwd) + const entry = { result, expiresAt: Number.POSITIVE_INFINITY } + probes.set(key, entry) + if (probes.size > MAX_PROBES) { + const oldest = probes.keys().next().value + if (oldest !== undefined) { + probes.delete(oldest) + } + } + const backend = await result + entry.expiresAt = Date.now() + (backend ? 60_000 : 5_000) + return backend + } catch { + return null + } +} + +async function probeBackend( + program: string, + environment: NodeJS.ProcessEnv, + cwd: string +): Promise { + try { + const result = await runProcess({ + program, + args: ['--version'], + env: environment, + cwd, + timeoutMs: 5_000, + maxOutputBytes: 1_024 + }) + if (result.code !== 0 || result.timedOut || result.outputTruncated) { + return null + } + const version = /^(?:opencode\s+)?v?([12])\.\d+\.\d+(?:[-+][\w.-]+)?$/i.exec( + result.stdout.trim() + ) + return version?.[1] === '1' ? 'v1' : version?.[1] === '2' ? 'v2' : null + } catch { + return null + } +} diff --git a/src/main/rate-limits/opencode-go-api-key-source.test.ts b/src/main/rate-limits/opencode-go-api-key-source.test.ts index b463cea5b9ff..32ef11ae1201 100644 --- a/src/main/rate-limits/opencode-go-api-key-source.test.ts +++ b/src/main/rate-limits/opencode-go-api-key-source.test.ts @@ -1,21 +1,31 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import Database from '../sqlite/sync-database' +import { detectOpenCodeCredentialBackend } from '../opencode/opencode-credential-backend' import { getOpenCodeAuthFilePath, readOpenCodeAuthFileGoKey, resolveOpenCodeGoApiKey } from './opencode-go-api-key-source' +vi.mock('../opencode/opencode-credential-backend', () => ({ + detectOpenCodeCredentialBackend: vi.fn() +})) + // Placeholder values only — a real key must never reach a fixture. const SETTINGS_KEY = 'settings-placeholder-key' const ENVIRONMENT_KEY = 'environment-placeholder-key' const AUTH_FILE_KEY = 'auth-file-placeholder-key' const DATABASE_KEY = 'database-placeholder-key' -const ENVIRONMENT_KEYS = ['XDG_DATA_HOME', 'OPENCODE_API_KEY', 'OPENCODE_DB'] as const +const ENVIRONMENT_KEYS = [ + 'XDG_DATA_HOME', + 'OPENCODE_API_KEY', + 'OPENCODE_DB', + 'OPENCODE_AUTH_CONTENT' +] as const describe('resolveOpenCodeGoApiKey', () => { let dataHome: string @@ -48,10 +58,12 @@ describe('resolveOpenCodeGoApiKey', () => { } beforeEach(() => { + vi.mocked(detectOpenCodeCredentialBackend).mockReset().mockResolvedValue('v2') originalEnvironment = Object.fromEntries(ENVIRONMENT_KEYS.map((key) => [key, process.env[key]])) dataHome = mkdtempSync(join(tmpdir(), 'orca-opencode-go-key-')) process.env.XDG_DATA_HOME = dataHome delete process.env.OPENCODE_API_KEY + delete process.env.OPENCODE_AUTH_CONTENT // Keeps the credential-database tier from touching the developer's own store. process.env.OPENCODE_DB = ':memory:' }) @@ -107,13 +119,13 @@ describe('resolveOpenCodeGoApiKey', () => { }) }) - it('falls back to the key OpenCode 1.x saved on /connect when the table is empty', async () => { + it('uses the key OpenCode 1.x saved on /connect', async () => { writeAuthFile({ anthropic: { type: 'oauth', refresh: 'r', access: 'a', expires: 1 }, 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) - await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ + await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({ status: 'found', key: AUTH_FILE_KEY, tier: 'opencode-auth-file' @@ -154,6 +166,171 @@ describe('resolveOpenCodeGoApiKey', () => { }) }) + it('uses v1 auth.json despite a conflicting populated credential table', async () => { + process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) + const { path } = writeCredentialDatabase([ + { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 } + ]) + process.env.OPENCODE_DB = path + + await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({ + status: 'found', + key: AUTH_FILE_KEY, + tier: 'opencode-auth-file' + }) + }) + + it('does not use a v2 table-only credential for v1 execution', async () => { + process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY + const { path } = writeCredentialDatabase([ + { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 } + ]) + process.env.OPENCODE_DB = path + + await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({ + status: 'found', + key: ENVIRONMENT_KEY, + tier: 'environment' + }) + }) + + it('applies the installed v1 backend even when the table has a key', async () => { + vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue('v1') + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) + process.env.OPENCODE_DB = writeCredentialDatabase([ + { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 } + ]).path + + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ + status: 'found', + key: AUTH_FILE_KEY, + tier: 'opencode-auth-file' + }) + }) + + it('uses v1 inline authentication ahead of a conflicting auth file and table', async () => { + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) + process.env.OPENCODE_DB = writeCredentialDatabase([ + { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 } + ]).path + process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ + 'opencode-go': { type: 'api', key: 'inline-placeholder-key' } + }) + + await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({ + status: 'found', + key: 'inline-placeholder-key', + tier: 'opencode-auth-content' + }) + }) + + it('does not fall back to the auth file when valid v1 inline auth omits Go', async () => { + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) + process.env.OPENCODE_AUTH_CONTENT = '{}' + process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY + + await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({ + status: 'found', + key: ENVIRONMENT_KEY, + tier: 'environment' + }) + }) + + it('uses the v1 auth file after malformed inline authentication', async () => { + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) + process.env.OPENCODE_AUTH_CONTENT = '{invalid' + + await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({ + status: 'found', + key: AUTH_FILE_KEY, + tier: 'opencode-auth-file' + }) + }) + + it('withholds stored credentials when the execution backend cannot be determined', async () => { + vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue(null) + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) + process.env.OPENCODE_DB = writeCredentialDatabase([ + { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 } + ]).path + + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ status: 'missing' }) + }) + + it('returns the manual override before reading execution context or probing a CLI', async () => { + const input = { settingsOverride: SETTINGS_KEY } + Object.defineProperty(input, 'environment', { + get: () => { + throw new Error('Selected profile metadata is unreadable') + } + }) + vi.mocked(detectOpenCodeCredentialBackend).mockRejectedValue(new Error('Unexpected probe')) + + await expect(resolveOpenCodeGoApiKey(input)).resolves.toEqual({ + status: 'found', + key: SETTINGS_KEY, + tier: 'settings' + }) + expect(detectOpenCodeCredentialBackend).not.toHaveBeenCalled() + }) + + it('propagates unreadable selected context instead of reading host credentials', async () => { + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) + const input = {} + Object.defineProperty(input, 'environment', { + get: () => { + throw new Error('Selected profile metadata is unreadable') + } + }) + + await expect(resolveOpenCodeGoApiKey(input)).rejects.toThrow( + 'Selected profile metadata is unreadable' + ) + }) + + it.each(['v1', 'v2'] as const)( + 'keeps System and selected data roots isolated for %s', + async (backend) => { + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) + const system = { XDG_DATA_HOME: dataHome, OPENCODE_DB: ':memory:' } + const selectedDataHome = join(dataHome, 'selected') + mkdirSync(join(selectedDataHome, 'opencode'), { recursive: true }) + writeFileSync( + join(selectedDataHome, 'opencode', 'auth.json'), + JSON.stringify({ + 'opencode-go': { type: 'api', key: 'selected-auth-placeholder' } + }) + ) + const { path } = writeCredentialDatabase([ + { + value: JSON.stringify({ type: 'key', key: 'selected-table-placeholder' }), + active: 1, + created: 1 + } + ]) + const selected = { XDG_DATA_HOME: selectedDataHome, OPENCODE_DB: path } + const inherited = { ...process.env } + + const first = await resolveOpenCodeGoApiKey({ environment: system, backend }) + const managed = await resolveOpenCodeGoApiKey({ environment: selected, backend }) + const restored = await resolveOpenCodeGoApiKey({ environment: system, backend }) + + expect(first).toEqual({ status: 'found', key: AUTH_FILE_KEY, tier: 'opencode-auth-file' }) + expect(managed).toEqual( + backend === 'v1' + ? { status: 'found', key: 'selected-auth-placeholder', tier: 'opencode-auth-file' } + : { + status: 'found', + key: 'selected-table-placeholder', + tier: 'opencode-credential-database' + } + ) + expect(restored).toEqual(first) + expect(process.env).toEqual(inherited) + } + ) + it('keeps the settings override above the credential table', async () => { const { path } = writeCredentialDatabase([ { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 } diff --git a/src/main/rate-limits/opencode-go-api-key-source.ts b/src/main/rate-limits/opencode-go-api-key-source.ts index eecfeec1ea7a..ec0af29acf7d 100644 --- a/src/main/rate-limits/opencode-go-api-key-source.ts +++ b/src/main/rate-limits/opencode-go-api-key-source.ts @@ -8,6 +8,10 @@ import { tableExists } from '../opencode-usage/schema-helpers' import { isWslUncPath } from '../../shared/wsl-paths' import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory' import Database from '../sqlite/sync-database' +import { + detectOpenCodeCredentialBackend, + type OpenCodeCredentialBackend +} from '../opencode/opencode-credential-backend' /** OpenCode's provider/integration id for the Go subscription. */ const OPENCODE_GO_INTEGRATION_ID = 'opencode-go' @@ -20,6 +24,7 @@ const MAX_AUTH_FILE_BYTES = 1_000_000 export type OpenCodeGoApiKeyTier = | 'settings' | 'environment' + | 'opencode-auth-content' | 'opencode-auth-file' | 'opencode-credential-database' @@ -54,6 +59,27 @@ function trimmedKey(value: unknown): string | null { return trimmed ? trimmed : null } +function readOpenCodeInlineGoKey(environment: NodeJS.ProcessEnv): { + configured: boolean + key: string | null +} { + const content = environment.OPENCODE_AUTH_CONTENT + if (content) { + try { + const parsed: unknown = JSON.parse(content) + return { + configured: true, + key: isRecord(parsed) + ? keyFromCredentialRecord(parsed[OPENCODE_GO_INTEGRATION_ID], 'api') + : null + } + } catch { + // V1 ignores invalid inline JSON and then reads auth.json. + } + } + return { configured: false, key: null } +} + /** * Read the `opencode-go` API key OpenCode 1.x writes on `/connect`. * @@ -80,8 +106,6 @@ export function readOpenCodeAuthFileGoKey( } return keyFromCredentialRecord(parsed[OPENCODE_GO_INTEGRATION_ID], 'api') } catch { - // Why: a malformed or unreadable auth file is "no key here", not a fetch - // failure — later tiers and the cookie path still deserve their turn. return null } } @@ -120,14 +144,18 @@ function selectCredentialKey(database: Database.Database): string | null { * OpenCode 2 imports `auth.json` into SQLite once (migration * `20260805200742_import_legacy_credentials`) and every later `/connect` writes * only there, so a fresh OpenCode 2 install has no `auth.json` entry at all. - * The table itself is not a version marker — 1.18.x creates it too (verified - * empty on a real 1.18.16 install), so probe it regardless of version. + * V1 also creates this table and can populate it through integration routes; + * only a caller with V2 execution authority should use it for Go credentials. * @returns The key, or null when no database, table, or row carries one. */ -export async function readOpenCodeCredentialDatabaseGoKey(): Promise { +export async function readOpenCodeCredentialDatabaseGoKey( + environment: NodeJS.ProcessEnv = process.env +): Promise { let paths: string[] try { - paths = [...(await listOpenCodeDatabases())].sort(compareOpenCodeClaimPriority) + paths = [...(await listOpenCodeDatabases(undefined, environment))].sort( + compareOpenCodeClaimPriority + ) } catch { return null } @@ -159,13 +187,9 @@ export async function readOpenCodeCredentialDatabaseGoKey(): Promise { - const environment = input.environment ?? process.env const override = trimmedKey(input.settingsOverride) if (override) { return { status: 'found', key: override, tier: 'settings' } } - const fromDatabase = await readOpenCodeCredentialDatabaseGoKey() - if (fromDatabase) { - return { status: 'found', key: fromDatabase, tier: 'opencode-credential-database' } + const environment = input.environment ?? process.env + const backend = input.backend ?? (await detectOpenCodeCredentialBackend(environment, input.cwd)) + if (!backend) { + return { status: 'missing' } + } + if (backend === 'v2') { + const fromDatabase = await readOpenCodeCredentialDatabaseGoKey(environment) + if (fromDatabase) { + return { status: 'found', key: fromDatabase, tier: 'opencode-credential-database' } + } + } + const inline = backend === 'v1' ? readOpenCodeInlineGoKey(environment) : null + if (inline?.key) { + return { status: 'found', key: inline.key, tier: 'opencode-auth-content' } } - const fromAuthFile = readOpenCodeAuthFileGoKey(environment) + const fromAuthFile = inline?.configured ? null : readOpenCodeAuthFileGoKey(environment) if (fromAuthFile) { return { status: 'found', key: fromAuthFile, tier: 'opencode-auth-file' } } diff --git a/src/main/rate-limits/opencode-go-usage-source-selection.test.ts b/src/main/rate-limits/opencode-go-usage-source-selection.test.ts index c907705b45e2..7c4372b65334 100644 --- a/src/main/rate-limits/opencode-go-usage-source-selection.test.ts +++ b/src/main/rate-limits/opencode-go-usage-source-selection.test.ts @@ -76,6 +76,44 @@ describe('fetchOpenCodeGoUsage', () => { expect(resolveApiKeyMock).toHaveBeenCalledWith({ settingsOverride: API_KEY }) }) + it('passes trusted execution context and selected roots through to credential lookup', async () => { + resolveApiKeyMock.mockResolvedValue({ status: 'missing' }) + const environment = { XDG_DATA_HOME: '/task/selected', OPENCODE_DB: ':memory:' } + + await fetchOpenCodeGoUsage({ cookie: '', backend: 'v1', environment, cwd: '/task/workspace' }) + + expect(resolveApiKeyMock).toHaveBeenCalledExactlyOnceWith({ + settingsOverride: undefined, + backend: 'v1', + environment, + cwd: '/task/workspace' + }) + }) + + it('passes a manual override without evaluating selected execution context', async () => { + resolveApiKeyMock.mockResolvedValue({ status: 'found', key: API_KEY, tier: 'settings' }) + fetchWithApiKeyMock.mockResolvedValue({ kind: 'ok', windows: WINDOWS }) + const input = { cookie: '', settingsApiKey: API_KEY } + Object.defineProperty(input, 'environment', { + get: () => { + throw new Error('Selected profile metadata is unreadable') + } + }) + + expect((await fetchOpenCodeGoUsage(input)).status).toBe('ok') + expect(resolveApiKeyMock).toHaveBeenCalledExactlyOnceWith({ settingsOverride: API_KEY }) + }) + + it('propagates a rejected selected-account lookup without trying host or cookie credentials', async () => { + resolveApiKeyMock.mockRejectedValue(new Error('Selected profile metadata is unreadable')) + + await expect(fetchOpenCodeGoUsage({ cookie: COOKIE })).rejects.toThrow( + 'Selected profile metadata is unreadable' + ) + expect(fetchWithApiKeyMock).not.toHaveBeenCalled() + expect(fetchWithCookieMock).not.toHaveBeenCalled() + }) + it('names the missing subscription instead of a generic refresh failure', async () => { resolveApiKeyMock.mockResolvedValue({ status: 'found', diff --git a/src/main/rate-limits/opencode-go-usage-source-selection.ts b/src/main/rate-limits/opencode-go-usage-source-selection.ts index 9d5399a585ed..994ff2d77028 100644 --- a/src/main/rate-limits/opencode-go-usage-source-selection.ts +++ b/src/main/rate-limits/opencode-go-usage-source-selection.ts @@ -5,6 +5,7 @@ import { type OpenCodeGoApiKeyResolution } from './opencode-go-api-key-source' import type { OpenCodeGoUsageWindows } from './opencode-go-status-parsing' +import type { OpenCodeCredentialBackend } from '../opencode/opencode-credential-backend' import { fetchOpenCodeGoUsageWithApiKey, type OpenCodeGoUsageApiOutcome @@ -14,6 +15,9 @@ import { fetchOpenCodeGoRateLimits, normalizeCookieInput } from './opencode-go-u export type OpenCodeGoUsageSourceInput = { /** Explicit Orca override; the highest-precedence key tier. */ settingsApiKey?: string + environment?: NodeJS.ProcessEnv + backend?: OpenCodeCredentialBackend + cwd?: string cookie: string workspaceIdOverride?: string networkProxySettings?: NetworkProxySettings @@ -96,8 +100,16 @@ function apiFailureResult( export async function fetchOpenCodeGoUsage( input: OpenCodeGoUsageSourceInput ): Promise { + const context = input.settingsApiKey?.trim() + ? {} + : { + ...(input.environment ? { environment: input.environment } : {}), + ...(input.backend ? { backend: input.backend } : {}), + ...(input.cwd ? { cwd: input.cwd } : {}) + } const apiKeyResolution = await resolveOpenCodeGoApiKey({ - settingsOverride: input.settingsApiKey + settingsOverride: input.settingsApiKey, + ...context }) input.onApiKeyResolved?.(apiKeyResolution) const hasCookie = Boolean(normalizeCookieInput(input.cookie)) From 2a8a26c03d87cd98ee5682b4ad758172f5e1dc08 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 07:57:38 -0700 Subject: [PATCH 18/24] Use host-private account restoration for OpenCode Go usage --- .../rate-limits/opencode-go-api-key-source.ts | 8 +- ...opencode-go-managed-account-source.test.ts | 241 ++++++++++++++++++ 2 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 src/main/rate-limits/opencode-go-managed-account-source.test.ts diff --git a/src/main/rate-limits/opencode-go-api-key-source.ts b/src/main/rate-limits/opencode-go-api-key-source.ts index ec0af29acf7d..1e65c445e89e 100644 --- a/src/main/rate-limits/opencode-go-api-key-source.ts +++ b/src/main/rate-limits/opencode-go-api-key-source.ts @@ -8,6 +8,7 @@ import { tableExists } from '../opencode-usage/schema-helpers' import { isWslUncPath } from '../../shared/wsl-paths' import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory' import Database from '../sqlite/sync-database' +import { getManagedDataAccountService } from '../managed-data-accounts/service' import { detectOpenCodeCredentialBackend, type OpenCodeCredentialBackend @@ -204,7 +205,12 @@ export async function resolveOpenCodeGoApiKey(input: { if (override) { return { status: 'found', key: override, tier: 'settings' } } - const environment = input.environment ?? process.env + const environment = input.environment ?? { ...process.env } + if (!input.environment) { + const accounts = getManagedDataAccountService() + accounts.restoreOriginalEnvironment(environment) + Object.assign(environment, accounts.launchEnvironment('opencode')) + } const backend = input.backend ?? (await detectOpenCodeCredentialBackend(environment, input.cwd)) if (!backend) { return { status: 'missing' } diff --git a/src/main/rate-limits/opencode-go-managed-account-source.test.ts b/src/main/rate-limits/opencode-go-managed-account-source.test.ts new file mode 100644 index 000000000000..c6ac24978d5b --- /dev/null +++ b/src/main/rate-limits/opencode-go-managed-account-source.test.ts @@ -0,0 +1,241 @@ +import { randomUUID } from 'node:crypto' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getAppEnvironment, setAppEnvironment } from '../../shared/app-environment' +import { getManagedDataAccountService } from '../managed-data-accounts/service' +import { detectOpenCodeCredentialBackend } from '../opencode/opencode-credential-backend' +import Database from '../sqlite/sync-database' +import { resolveOpenCodeGoApiKey } from './opencode-go-api-key-source' + +vi.mock('../opencode/opencode-credential-backend', () => ({ + detectOpenCodeCredentialBackend: vi.fn() +})) + +let root: string +let systemDataHome: string +let metadataPath: string + +function writeCredentials(dataHome: string, name: string): void { + const directory = join(dataHome, 'opencode') + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(directory, 'auth.json'), + JSON.stringify({ 'opencode-go': { type: 'api', key: `${name}-auth-placeholder` } }) + ) + const database = new Database(join(directory, 'opencode.db')) + try { + database.exec( + 'CREATE TABLE session (id TEXT); ' + + 'CREATE TABLE credential (integration_id TEXT, value TEXT, active INTEGER, time_created INTEGER)' + ) + database + .prepare('INSERT INTO credential VALUES (?, ?, 1, 1)') + .run('opencode-go', JSON.stringify({ type: 'key', key: `${name}-table-placeholder` })) + } finally { + database.close() + } +} + +async function enrollSelectedAccount(): Promise { + const accounts = getManagedDataAccountService() + const source = join(root, 'source') + writeCredentials(source, 'selected') + const state = await accounts.add('opencode', source, 'Selected') + const selected = accounts.launchEnvironment('opencode') + writeFileSync( + join(selected.XDG_DATA_HOME, 'opencode', 'auth.json'), + readFileSync(join(source, 'opencode', 'auth.json')) + ) + await accounts.select('opencode', null) + return state.accounts[0].id +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-go-managed-caller-')) + systemDataHome = join(root, 'system-data') + const original = getAppEnvironment() + setAppEnvironment({ + ...original, + getPath: (name) => (name === 'userData' ? root : original.getPath(name)) + }) + metadataPath = join(root, 'managed-data-accounts', 'opencode', 'accounts.json') + for (const key of Object.keys(process.env)) { + if (key.startsWith('ORCA_DATA_ACCOUNT_')) { + vi.stubEnv(key, undefined) + } + } + vi.stubEnv('HOME', join(root, 'home')) + vi.stubEnv('XDG_DATA_HOME', systemDataHome) + vi.stubEnv('XDG_STATE_HOME', join(root, 'system-state')) + vi.stubEnv('XDG_CONFIG_HOME', join(root, 'config')) + vi.stubEnv('XDG_CACHE_HOME', join(root, 'cache')) + vi.stubEnv('OPENCODE_DB', 'opencode.db') + vi.stubEnv('OPENCODE_AUTH_CONTENT', undefined) + vi.stubEnv('OPENCODE_API_KEY', undefined) + vi.mocked(detectOpenCodeCredentialBackend).mockReset() + writeCredentials(systemDataHome, 'system') +}) + +afterEach(() => { + vi.restoreAllMocks() + getManagedDataAccountService().clearInlineAuthBaselines() + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) +}) + +describe('OpenCode Go execution-host managed account lookup', () => { + it.each(['v1', 'v2'] as const)( + 'uses System → selected → System credentials on %s', + async (backend) => { + vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue(backend) + const id = await enrollSelectedAccount() + const accounts = getManagedDataAccountService() + const inherited = { ...process.env } + const tier = backend === 'v1' ? 'opencode-auth-file' : 'opencode-credential-database' + const suffix = backend === 'v1' ? 'auth' : 'table' + + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ + status: 'found', + key: `system-${suffix}-placeholder`, + tier + }) + await accounts.select('opencode', id) + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ + status: 'found', + key: `selected-${suffix}-placeholder`, + tier + }) + expect(detectOpenCodeCredentialBackend).toHaveBeenLastCalledWith( + expect.objectContaining(accounts.launchEnvironment('opencode')), + undefined + ) + await accounts.select('opencode', null) + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ + status: 'found', + key: `system-${suffix}-placeholder`, + tier + }) + expect(process.env).toEqual(inherited) + } + ) + + it.each(['v1', 'v2'] as const)( + 'restores an opaque inline System baseline through the host service on %s', + async (backend) => { + vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue(backend) + const id = await enrollSelectedAccount() + const accounts = getManagedDataAccountService() + await accounts.select('opencode', id) + const selected = accounts.launchEnvironment('opencode') + const inline = JSON.stringify({ + 'opencode-go': { type: 'api', key: 'system-inline-placeholder' } + }) + const baseline: Record = { + XDG_DATA_HOME: systemDataHome, + XDG_STATE_HOME: join(root, 'system-state'), + OPENCODE_DB: 'opencode.db', + OPENCODE_AUTH_CONTENT: inline + } + accounts.captureOriginalEnvironment(baseline, selected) + const inheritedProfile: Record = { + ...baseline, + ...selected, + ORCA_DATA_ACCOUNT_PROVIDER: 'opencode', + ORCA_DATA_ACCOUNT_DATA_HOME: selected.XDG_DATA_HOME, + ORCA_DATA_ACCOUNT_STATE_HOME: selected.XDG_STATE_HOME + } + expect(inheritedProfile.ORCA_DATA_ACCOUNT_ORIGINAL_ENV).not.toContain( + 'system-inline-placeholder' + ) + expect(JSON.parse(baseline.ORCA_DATA_ACCOUNT_ORIGINAL_ENV)).toMatchObject({ + OPENCODE_AUTH_CONTENT: null, + inlineAuthReference: expect.any(String) + }) + for (const [key, value] of Object.entries(inheritedProfile)) { + vi.stubEnv(key, value) + } + const inherited = { ...process.env } + + await accounts.select('opencode', null) + const expected = + backend === 'v1' + ? { status: 'found', key: 'system-inline-placeholder', tier: 'opencode-auth-content' } + : { + status: 'found', + key: 'system-table-placeholder', + tier: 'opencode-credential-database' + } + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual(expected) + await accounts.select('opencode', id) + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ + status: 'found', + key: backend === 'v1' ? 'selected-auth-placeholder' : 'selected-table-placeholder', + tier: backend === 'v1' ? 'opencode-auth-file' : 'opencode-credential-database' + }) + await accounts.select('opencode', null) + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual(expected) + expect(process.env).toEqual(inherited) + } + ) + + it.each(['malformed', 'unknown', 'unreadable'] as const)( + 'rejects %s metadata before probing or falling back, while a manual key still wins', + async (failure) => { + mkdirSync(join(root, 'managed-data-accounts', 'opencode'), { recursive: true }) + if (failure === 'unreadable') { + mkdirSync(metadataPath) + } else { + writeFileSync( + metadataPath, + failure === 'malformed' + ? '{invalid' + : JSON.stringify({ + accounts: [], + activeAccountId: randomUUID() + }) + ) + } + const before = failure === 'unreadable' ? null : readFileSync(metadataPath, 'utf8') + const accounts = getManagedDataAccountService() + const restore = vi.spyOn(accounts, 'restoreOriginalEnvironment') + const selection = vi.spyOn(accounts, 'launchEnvironment') + const inherited = { ...process.env } + + await expect( + resolveOpenCodeGoApiKey({ settingsOverride: ' manual-placeholder ' }) + ).resolves.toEqual({ + status: 'found', + key: 'manual-placeholder', + tier: 'settings' + }) + expect(restore).not.toHaveBeenCalled() + expect(selection).not.toHaveBeenCalled() + expect(process.env).toEqual(inherited) + await expect(resolveOpenCodeGoApiKey({})).rejects.toThrow() + expect(detectOpenCodeCredentialBackend).not.toHaveBeenCalled() + if (before !== null) { + expect(readFileSync(metadataPath, 'utf8')).toBe(before) + } + expect(process.env).toEqual(inherited) + } + ) + + it('returns a manual key without resolving the host service or mutating the inherited environment', async () => { + const inherited = { ...process.env } + vi.spyOn(getAppEnvironment(), 'getPath').mockImplementation(() => { + throw new Error('Host metadata must not be resolved') + }) + await expect( + resolveOpenCodeGoApiKey({ settingsOverride: 'manual-placeholder' }) + ).resolves.toEqual({ + status: 'found', + key: 'manual-placeholder', + tier: 'settings' + }) + expect(detectOpenCodeCredentialBackend).not.toHaveBeenCalled() + expect(process.env).toEqual(inherited) + vi.restoreAllMocks() + }) +}) From fa9f6a0a57f202d0c11d8786db3807a941d52c69 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 18:05:26 -0700 Subject: [PATCH 19/24] 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 da6d45c8a230..fd45c42705fb 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 000000000000..452629732ac6 --- /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 d1b4e2d7b979..7e715a066169 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 22de0bbe2e28..6a2036aceda3 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 d9ad4cd2c12e..8c247bbd5f1f 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 5393b55ea0d8..4b51caa27420 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 20/24] 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 | 278 +++++++++++++++++- src/main/managed-data-accounts/service.ts | 79 ++--- 4 files changed, 515 insertions(+), 106 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 6a2036aceda3..dd077d4aa05e 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 000000000000..25f678317e80 --- /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 8c247bbd5f1f..5ab7f12d04c9 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) { + 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') } - rmSync(directory, { recursive: true, force: true }) + return rename(from, to) }) - 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() @@ -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 4b51caa27420..180565d23d4d 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 { From 1431547578d0776679cfc4ae30205eda3d43b51e Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 23:47:49 -0700 Subject: [PATCH 21/24] fix(accounts): canonicalize account removal to rm Use account rm as the canonical removal command and keep account remove as an alias. Preserve the existing accounts.removeData RPC and the full original 63-path account component. Original-Account-Source: cf71ae4cb6f8202a7cc8a424aa84d127c845cbe2 Frozen-Account-Base: 08ee7ba9efa2f3842b7a057a1eb101c824ea0087 Frozen-Integrated-Main: 53f9ea783986d0a336e079e8ed8d59a85552ca4c Private validation source only; no ref or publication. --- docs/reference/managed-data-accounts.md | 2 +- src/cli/handler-group-manifest.ts | 2 +- src/cli/handlers/account.ts | 4 +- src/cli/root-help-text-primary.ts | 2 +- src/cli/specs/account.test.ts | 53 ++++++++++++++++++++++++- src/cli/specs/account.ts | 5 ++- 6 files changed, 59 insertions(+), 9 deletions(-) diff --git a/docs/reference/managed-data-accounts.md b/docs/reference/managed-data-accounts.md index 7967ab231e5a..c0a78d001d51 100644 --- a/docs/reference/managed-data-accounts.md +++ b/docs/reference/managed-data-accounts.md @@ -9,7 +9,7 @@ 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 +orca account rm --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. diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts index c27ea5108263..6fd8588da2f8 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', 'account select', 'account remove'], + keys: ['account add', 'account list', 'account select', 'account rm'], load: async () => (await import('./handlers/account.js')).ACCOUNT_HANDLERS }, { diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index 19d7cac1b49f..d4081c3c5da1 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -330,8 +330,8 @@ export const ACCOUNT_HANDLERS: Record = { rejectAccountRemoteSelectionFlags(ctx, 'orca account select') await mutateDataAccount(ctx, 'select') }, - 'account remove': async (ctx) => { - rejectAccountRemoteSelectionFlags(ctx, 'orca account remove') + 'account rm': async (ctx) => { + rejectAccountRemoteSelectionFlags(ctx, 'orca account rm') await mutateDataAccount(ctx, 'remove') } } diff --git a/src/cli/root-help-text-primary.ts b/src/cli/root-help-text-primary.ts index 6d6a73537e2f..55d698c670a6 100644 --- a/src/cli/root-help-text-primary.ts +++ b/src/cli/root-help-text-primary.ts @@ -21,7 +21,7 @@ export const ROOT_HELP_TEXT_PRIMARY = [ ' 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', + ' account rm 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 1ab5d61b116b..8cbe3f9c86a0 100644 --- a/src/cli/specs/account.test.ts +++ b/src/cli/specs/account.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it } from 'vitest' import { ACCOUNT_COMMAND_SPECS } from './account' -import { effectiveAllowedFlags } from '../args' +import { + effectiveAllowedFlags, + findCommandSpec, + normalizeCommandPositionals, + parseArgs, + specPaths, + validateCommandAndFlags +} from '../args' +import { HANDLER_COMMAND_KEYS } from '../dispatch' import { formatCommandHelp } from '../help' function spec(path: string): (typeof ACCOUNT_COMMAND_SPECS)[number] { @@ -53,8 +61,49 @@ describe('account command specs', () => { }) it('describes the supported providers for profile selection and removal', () => { - for (const command of ['account list', 'account select', 'account remove']) { + for (const command of ['account list', 'account select', 'account rm']) { expect(formatCommandHelp(spec(command))).toContain('Account provider: opencode or devin') } }) + + it.each([ + ['rm', 'opencode'], + ['remove', 'opencode'], + ['rm', 'devin'], + ['remove', 'devin'] + ])('routes account %s for %s through the canonical removal command', (verb, provider) => { + const paths = ACCOUNT_COMMAND_SPECS.flatMap(specPaths) + const parsed = normalizeCommandPositionals( + ACCOUNT_COMMAND_SPECS, + parseArgs( + ['account', verb, '--agent', provider, '--account', 'profile-1', '--json'], + paths, + ACCOUNT_COMMAND_SPECS + ) + ) + + expect(parsed.commandPath).toEqual(['account', 'rm']) + expect(parsed.flags.get('agent')).toBe(provider) + expect(parsed.flags.get('account')).toBe('profile-1') + expect(parsed.flags.get('json')).toBe(true) + expect(() => validateCommandAndFlags(ACCOUNT_COMMAND_SPECS, parsed)).not.toThrow() + expect(HANDLER_COMMAND_KEYS.has(parsed.commandPath.join(' '))).toBe(true) + }) + + it('shows canonical removal help for the existing remove alias', () => { + const canonical = spec('account rm') + + expect(findCommandSpec(ACCOUNT_COMMAND_SPECS, ['account', 'remove'])).toBe(canonical) + expect(formatCommandHelp(canonical)).toContain('orca account rm --agent opencode|devin') + expect(HANDLER_COMMAND_KEYS.has('account remove')).toBe(false) + }) + + it('rejects an unregistered account deletion verb', () => { + expect(() => + validateCommandAndFlags(ACCOUNT_COMMAND_SPECS, { + commandPath: ['account', 'delete'], + flags: new Map() + }) + ).toThrow('Unknown command: account delete') + }) }) diff --git a/src/cli/specs/account.ts b/src/cli/specs/account.ts index 0411bdbd1d06..64bba5e9a1e9 100644 --- a/src/cli/specs/account.ts +++ b/src/cli/specs/account.ts @@ -40,9 +40,10 @@ export const ACCOUNT_COMMAND_SPECS: CommandSpec[] = [ allowedFlags: [...GLOBAL_FLAGS, 'agent', 'account'] }, { - path: ['account', 'remove'], + path: ['account', 'rm'], + aliases: [['account', 'remove']], summary: 'Remove a managed OpenCode or Devin profile and its private data', - usage: 'orca account remove --agent opencode|devin --account [--json]', + usage: 'orca account rm --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.' From c2fb8c89c414d20070751484f354cdd522de8e1a Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 3 Oct 2026 00:46:17 -0700 Subject: [PATCH 22/24] fix(accounts): recover interrupted profile removal on startup Scan private removal backups before quarantined cleanup, reuse the existing state schema to validate the exact UUID, and preserve registered or unmarked profiles. Mark account rm as destructive using the existing typo recovery policy. Retain the full original account component and public author ancestry. Account-PR: 24636 Original-Account-Source: cf71ae4cb6f8202a7cc8a424aa84d127c845cbe2 Reviewed-Public-Parent: 6abaf736e3c302affdb9c2fb0e911e14baf62739 Frozen-Integrated-Main: 53f9ea783986d0a336e079e8ed8d59a85552ca4c Private source only; no ref or publication. --- src/cli/specs/account.test.ts | 27 ++ src/cli/specs/account.ts | 1 + .../profile-removal-recovery.test.ts | 304 ++++++++++++++++++ .../managed-data-accounts/profile-removal.ts | 69 +++- src/main/managed-data-accounts/service.ts | 1 + 5 files changed, 395 insertions(+), 7 deletions(-) create mode 100644 src/main/managed-data-accounts/profile-removal-recovery.test.ts diff --git a/src/cli/specs/account.test.ts b/src/cli/specs/account.test.ts index 8cbe3f9c86a0..6a4e209c3680 100644 --- a/src/cli/specs/account.test.ts +++ b/src/cli/specs/account.test.ts @@ -11,6 +11,7 @@ import { } from '../args' import { HANDLER_COMMAND_KEYS } from '../dispatch' import { formatCommandHelp } from '../help' +import { suggestCommands, unknownCommandData } from '../command-suggestion' function spec(path: string): (typeof ACCOUNT_COMMAND_SPECS)[number] { const found = ACCOUNT_COMMAND_SPECS.find((entry) => entry.path.join(' ') === path) @@ -98,6 +99,32 @@ describe('account command specs', () => { expect(HANDLER_COMMAND_KEYS.has('account remove')).toBe(false) }) + it.each(['move', 'go'])( + 'suggestion safety keeps benign account %s mistakes out of profile deletion', + (verb) => { + const path = ['account', verb] + expect(suggestCommands(ACCOUNT_COMMAND_SPECS, path)).not.toContain('account rm') + expect(suggestCommands(ACCOUNT_COMMAND_SPECS, path)).not.toContain('account remove') + const data = unknownCommandData(ACCOUNT_COMMAND_SPECS, path) + expect(data.nextSteps.join(' ')).not.toContain('orca account rm') + expect(data.nextSteps.join(' ')).not.toContain('orca account remove') + } + ) + + it('suggestion safety still recovers intended profile removal near-misses', () => { + const data = unknownCommandData(ACCOUNT_COMMAND_SPECS, ['account', 'remov']) + expect(data.suggestions).toContain('account rm') + expect(data.suggestions).toContain('account remove') + expect(data.nextSteps.join(' ')).toContain('orca account rm') + }) + + it('suggestion safety preserves non-destructive account list recovery', () => { + const suggestions = suggestCommands(ACCOUNT_COMMAND_SPECS, ['account', 'lst']) + expect(suggestions).toContain('account list') + expect(suggestions).not.toContain('account rm') + expect(suggestions).not.toContain('account remove') + }) + it('rejects an unregistered account deletion verb', () => { expect(() => validateCommandAndFlags(ACCOUNT_COMMAND_SPECS, { diff --git a/src/cli/specs/account.ts b/src/cli/specs/account.ts index 64bba5e9a1e9..3da205e31eb5 100644 --- a/src/cli/specs/account.ts +++ b/src/cli/specs/account.ts @@ -42,6 +42,7 @@ export const ACCOUNT_COMMAND_SPECS: CommandSpec[] = [ { path: ['account', 'rm'], aliases: [['account', 'remove']], + destructive: true, summary: 'Remove a managed OpenCode or Devin profile and its private data', usage: 'orca account rm --agent opencode|devin --account [--json]', allowedFlags: [...GLOBAL_FLAGS, 'agent', 'account'], diff --git a/src/main/managed-data-accounts/profile-removal-recovery.test.ts b/src/main/managed-data-accounts/profile-removal-recovery.test.ts new file mode 100644 index 000000000000..f0ccc305d6f1 --- /dev/null +++ b/src/main/managed-data-accounts/profile-removal-recovery.test.ts @@ -0,0 +1,304 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { randomUUID } from 'node:crypto' +import * as fileSystem from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ManagedDataAccountProvider } from '../../shared/managed-account-types' +import { writeSecureFile } from '../../shared/secure-file' +import { ManagedDataAccountService } from './service' + +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal()) +})) + +let root: string +let storage: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-account-removal-recovery-')) + storage = join(root, 'managed') + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +function interruptedRemoval(provider: ManagedDataAccountProvider = 'devin') { + const id = randomUUID() + const providerRoot = join(storage, provider) + const directory = join(providerRoot, id) + const credentialsPath = join(directory, 'data', 'test-credentials') + const metadataPath = join(providerRoot, 'accounts.json') + const rollbackPath = `${metadataPath}.${id}.rollback` + const before = { + accounts: [{ id, label: 'Private test profile', integrations: [], createdAt: 1 }], + activeAccountId: id + } + mkdirSync(join(directory, 'data'), { recursive: true }) + writeFileSync(credentialsPath, 'private-test-only-credential') + expect(writeSecureFile(rollbackPath, JSON.stringify(before), { durable: true })).toBe(true) + expect( + writeSecureFile(metadataPath, JSON.stringify({ accounts: [], activeAccountId: null }), { + durable: true + }) + ).toBe(true) + return { id, providerRoot, directory, credentialsPath, metadataPath, rollbackPath, before } +} + +async function finishStartup(service: ManagedDataAccountService) { + await service.select('devin', null) +} + +describe('interrupted managed account removal recovery', () => { + it.each(['devin', 'opencode'] as const)( + 'quarantines a committed %s removal left before quarantine on restart', + async (provider) => { + const fixture = interruptedRemoval(provider) + const restarted = new ManagedDataAccountService(storage) + expect(restarted.list(provider)).toEqual({ accounts: [], activeAccountId: null }) + expect(restarted.launchEnvironment(provider)).toEqual({}) + await finishStartup(restarted) + expect(existsSync(fixture.directory)).toBe(false) + expect(existsSync(fixture.rollbackPath)).toBe(false) + expect(restarted.list(provider)).toEqual({ accounts: [], activeAccountId: null }) + } + ) + + it('recovers after both quarantine and metadata rollback fail without the removed id', async () => { + const source = join(root, 'source') + mkdirSync(join(source, 'devin'), { recursive: true }) + writeFileSync(join(source, 'devin', 'credentials.toml'), 'windsurf_api_key = "test-only"') + const service = new ManagedDataAccountService(storage) + const before = await service.add('devin', source, 'Work') + const id = before.accounts[0].id + const directory = join(storage, 'devin', id) + const credentialsPath = join(directory, 'data', 'devin', 'credentials.toml') + const metadataPath = join(storage, 'devin', 'accounts.json') + const rollbackPath = `${metadataPath}.${id}.rollback` + const credentials = readFileSync(credentialsPath) + const original = readFileSync(metadataPath) + const rename = fileSystem.renameSync + const failingRename = vi.spyOn(fileSystem, 'renameSync').mockImplementation((from, to) => { + if (from === directory || from === rollbackPath) { + throw new Error('injected removal and rollback lock') + } + return rename(from, to) + }) + await expect(service.remove('devin', id)).rejects.toBeInstanceOf(AggregateError) + expect(service.list('devin')).toEqual({ accounts: [], activeAccountId: null }) + expect(readFileSync(credentialsPath)).toEqual(credentials) + expect(readFileSync(rollbackPath)).toEqual(original) + failingRename.mockRestore() + + const restarted = new ManagedDataAccountService(storage) + await finishStartup(restarted) + expect(existsSync(directory)).toBe(false) + expect(existsSync(rollbackPath)).toBe(false) + expect(restarted.list('devin')).toEqual({ accounts: [], activeAccountId: null }) + expect(readFileSync(join(source, 'devin', 'credentials.toml'), 'utf8')).toContain('test-only') + }) + + it('retains recovery evidence when quarantine is locked and continues other removals', async () => { + const locked = interruptedRemoval() + const other = interruptedRemoval() + const rename = fileSystem.renameSync + const failingRename = vi.spyOn(fileSystem, 'renameSync').mockImplementation((from, to) => { + if (from === locked.directory) { + throw new Error('quarantine locked') + } + return rename(from, to) + }) + const restarted = new ManagedDataAccountService(storage) + await finishStartup(restarted) + expect(existsSync(locked.directory)).toBe(true) + expect(readFileSync(locked.credentialsPath, 'utf8')).toBe('private-test-only-credential') + expect(existsSync(locked.rollbackPath)).toBe(true) + expect(existsSync(other.directory)).toBe(false) + expect(existsSync(other.rollbackPath)).toBe(false) + failingRename.mockRestore() + + const retry = new ManagedDataAccountService(storage) + await finishStartup(retry) + expect(existsSync(locked.directory)).toBe(false) + expect(existsSync(locked.rollbackPath)).toBe(false) + }) + + it('leaves failed cleanup quarantined and retries it without restoring metadata', async () => { + const fixture = interruptedRemoval() + const restarted = new ManagedDataAccountService(storage, () => { + throw new Error('cleanup locked') + }) + await finishStartup(restarted) + const pendingDirectory = join(fixture.providerRoot, '.pending-delete', fixture.id) + expect(existsSync(fixture.directory)).toBe(false) + expect(readFileSync(join(pendingDirectory, 'data', 'test-credentials'), 'utf8')).toBe( + 'private-test-only-credential' + ) + expect(restarted.list('devin')).toEqual({ accounts: [], activeAccountId: null }) + + const retry = new ManagedDataAccountService(storage) + await finishStartup(retry) + expect(existsSync(pendingDirectory)).toBe(false) + expect(existsSync(fixture.rollbackPath)).toBe(false) + expect(retry.list('devin')).toEqual({ accounts: [], activeAccountId: null }) + }) + + it('retains backup evidence while an original profile collides with its quarantine', async () => { + const fixture = interruptedRemoval() + const pendingDirectory = join(fixture.providerRoot, '.pending-delete', fixture.id) + mkdirSync(pendingDirectory, { recursive: true }) + writeFileSync(join(pendingDirectory, 'private-state'), 'earlier-quarantine-test-data') + const restarted = new ManagedDataAccountService(storage) + await finishStartup(restarted) + expect(existsSync(pendingDirectory)).toBe(false) + expect(readFileSync(fixture.credentialsPath, 'utf8')).toBe('private-test-only-credential') + expect(existsSync(fixture.rollbackPath)).toBe(true) + expect(restarted.list('devin')).toEqual({ accounts: [], activeAccountId: null }) + + const retry = new ManagedDataAccountService(storage) + await finishStartup(retry) + expect(existsSync(fixture.directory)).toBe(false) + expect(existsSync(fixture.rollbackPath)).toBe(false) + expect(retry.list('devin')).toEqual({ accounts: [], activeAccountId: null }) + }) + + it('preserves registered profiles, their markers and quarantines, and unmarked directories', async () => { + const fixture = interruptedRemoval() + expect(writeSecureFile(fixture.metadataPath, JSON.stringify(fixture.before))).toBe(true) + const pendingDirectory = join(fixture.providerRoot, '.pending-delete', fixture.id) + const unmarked = join(fixture.providerRoot, randomUUID()) + mkdirSync(pendingDirectory, { recursive: true }) + mkdirSync(unmarked) + writeFileSync(join(unmarked, 'private-data'), 'unregistered-is-not-removal-evidence') + const marker = readFileSync(fixture.rollbackPath) + const restarted = new ManagedDataAccountService(storage) + await restarted.select('devin', fixture.id) + expect(readFileSync(fixture.credentialsPath, 'utf8')).toBe('private-test-only-credential') + expect(readFileSync(fixture.rollbackPath)).toEqual(marker) + expect(existsSync(pendingDirectory)).toBe(true) + expect(readFileSync(join(unmarked, 'private-data'), 'utf8')).toBe( + 'unregistered-is-not-removal-evidence' + ) + expect(restarted.list('devin')).toEqual(fixture.before) + }) + + it.each(['invalid JSON', 'invalid state', 'different id', 'directory', 'missing marker'])( + 'preserves original credentials without valid removal evidence: %s', + async (invalid) => { + const fixture = interruptedRemoval() + if (invalid === 'invalid JSON') { + writeFileSync(fixture.rollbackPath, '{') + } else if (invalid === 'invalid state') { + writeFileSync(fixture.rollbackPath, JSON.stringify({ accounts: [{ id: fixture.id }] })) + } else if (invalid === 'different id') { + writeFileSync( + fixture.rollbackPath, + JSON.stringify({ ...fixture.before, accounts: [], activeAccountId: null }) + ) + } else { + rmSync(fixture.rollbackPath) + if (invalid === 'directory') { + mkdirSync(fixture.rollbackPath) + } + } + const restarted = new ManagedDataAccountService(storage) + await finishStartup(restarted) + expect(readFileSync(fixture.credentialsPath, 'utf8')).toBe('private-test-only-credential') + await expect(restarted.remove('devin', fixture.id)).rejects.toThrow() + expect(readFileSync(fixture.credentialsPath, 'utf8')).toBe('private-test-only-credential') + } + ) + + it('preserves unreadable backups and continues other eligible removals', async () => { + const unreadable = interruptedRemoval() + const other = interruptedRemoval() + const read = fileSystem.readFileSync + const failingRead = vi.spyOn(fileSystem, 'readFileSync').mockImplementation((...args) => { + if (args[0] === unreadable.rollbackPath) { + throw Object.assign(new Error('private backup unreadable'), { code: 'EACCES' }) + } + return read(...args) + }) + const restarted = new ManagedDataAccountService(storage) + await finishStartup(restarted) + expect(readFileSync(unreadable.credentialsPath, 'utf8')).toBe('private-test-only-credential') + expect(existsSync(unreadable.rollbackPath)).toBe(true) + expect(existsSync(other.directory)).toBe(false) + failingRead.mockRestore() + + const retry = new ManagedDataAccountService(storage) + await finishStartup(retry) + expect(existsSync(unreadable.directory)).toBe(false) + expect(existsSync(unreadable.rollbackPath)).toBe(false) + }) + + it('rejects a linked provider directory without deleting another provider profile', async () => { + const fixture = interruptedRemoval('opencode') + const alias = join(storage, 'devin') + symlinkSync(fixture.providerRoot, alias, 'junction') + expect(writeSecureFile(fixture.metadataPath, JSON.stringify(fixture.before))).toBe(true) + const restarted = new ManagedDataAccountService(storage) + await restarted.select('opencode', fixture.id) + expect(console.warn).toHaveBeenCalledWith( + '[managed-data-accounts] Could not retry private account directory cleanup.' + ) + expect(readFileSync(fixture.credentialsPath, 'utf8')).toBe('private-test-only-credential') + expect(existsSync(fixture.rollbackPath)).toBe(true) + }) + + it('ignores non-UUID markers and cleans only valid completed removal markers', async () => { + const fixture = interruptedRemoval() + rmSync(fixture.directory, { recursive: true }) + const invalid = join(fixture.providerRoot, 'accounts.json.unrecognized.rollback') + writeFileSync(invalid, JSON.stringify(fixture.before)) + const restarted = new ManagedDataAccountService(storage) + await finishStartup(restarted) + expect(existsSync(fixture.rollbackPath)).toBe(false) + expect(existsSync(invalid)).toBe(true) + }) + + it.skipIf(process.platform === 'win32')( + 'rejects a marker symlink without touching its target', + async () => { + const fixture = interruptedRemoval() + const target = join(root, 'outside-backup') + const backup = readFileSync(fixture.rollbackPath) + writeFileSync(target, backup) + rmSync(fixture.rollbackPath) + symlinkSync(target, fixture.rollbackPath) + const restarted = new ManagedDataAccountService(storage) + await finishStartup(restarted) + expect(readFileSync(fixture.credentialsPath, 'utf8')).toBe('private-test-only-credential') + await expect(restarted.remove('devin', fixture.id)).rejects.toThrow() + expect(readFileSync(target)).toEqual(backup) + expect(readFileSync(fixture.credentialsPath, 'utf8')).toBe('private-test-only-credential') + } + ) + + it.skipIf(process.platform === 'win32')( + 'rejects a profile symlink without touching its target', + async () => { + const fixture = interruptedRemoval() + const target = join(root, 'outside-profile') + mkdirSync(target) + writeFileSync(join(target, 'private-data'), 'outside-private-test-data') + rmSync(fixture.directory, { recursive: true }) + symlinkSync(target, fixture.directory) + const restarted = new ManagedDataAccountService(storage) + await finishStartup(restarted) + expect(readFileSync(join(target, 'private-data'), 'utf8')).toBe('outside-private-test-data') + expect(existsSync(fixture.rollbackPath)).toBe(true) + } + ) +}) diff --git a/src/main/managed-data-accounts/profile-removal.ts b/src/main/managed-data-accounts/profile-removal.ts index 25f678317e80..14c7c0c4adaf 100644 --- a/src/main/managed-data-accounts/profile-removal.ts +++ b/src/main/managed-data-accounts/profile-removal.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs' import { readdir } from 'node:fs/promises' import { dirname, join } from 'node:path' import { z } from 'zod' @@ -13,6 +13,7 @@ export class ManagedDataAccountProfileRemoval { constructor( private readonly root: string, private readonly assertOwned: (path: string) => void, + private readonly parseState: (contents: string) => ManagedDataAccountsState, private readonly removeDirectory: (directory: string) => void | Promise = removeHostTree ) {} @@ -31,11 +32,13 @@ export class ManagedDataAccountProfileRemoval { 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) + if (existsSync(directory)) { + if (!this.hasRemovalBackup(rollbackPath, accountId)) { + throw new Error('Managed account not found.') + } this.quarantine(directory, pendingDirectory) changed() - } else if (!existsSync(pendingDirectory)) { + } else if (!existsSync(pendingDirectory) && !this.hasRemovalBackup(rollbackPath, accountId)) { throw new Error('Managed account not found.') } this.discardBackup(rollbackPath) @@ -79,6 +82,7 @@ export class ManagedDataAccountProfileRemoval { if (!existsSync(directory)) { return } + this.assertOwned(dirname(directory)) this.assertOwned(directory) if (existsSync(pendingDirectory)) { throw new Error('Account already has a pending removal directory.') @@ -89,6 +93,49 @@ export class ManagedDataAccountProfileRemoval { renameSync(directory, pendingDirectory) } + private hasRemovalBackup(rollbackPath: string, accountId: string): boolean { + if (!existsSync(rollbackPath)) { + return false + } + this.assertOwned(dirname(rollbackPath)) + this.assertOwned(rollbackPath) + if (!lstatSync(rollbackPath).isFile()) { + return false + } + try { + const backup = this.parseState(readFileSync(rollbackPath, 'utf8')) + return backup.accounts.some((account) => account.id === accountId) + } catch { + return false + } + } + + private async retryBackups(providerRoot: string, registered: Set): Promise { + const entries = await readdir(providerRoot, { withFileTypes: true }) + for (const entry of entries) { + const accountId = /^accounts\.json\.(.+)\.rollback$/.exec(entry.name)?.[1] + if ( + !entry.isFile() || + !accountId || + !z.uuid().safeParse(accountId).success || + registered.has(accountId) + ) { + continue + } + const rollbackPath = join(providerRoot, entry.name) + try { + if (!this.hasRemovalBackup(rollbackPath, accountId)) { + continue + } + const pendingDirectory = join(providerRoot, '.pending-delete', accountId) + this.quarantine(join(providerRoot, accountId), pendingDirectory) + this.discardBackup(rollbackPath) + } catch { + console.warn('[managed-data-accounts] Could not recover interrupted account removal.') + } + } + } + private discardBackup(rollbackPath: string): void { try { rmSync(rollbackPath, { force: true }) @@ -113,11 +160,17 @@ export class ManagedDataAccountProfileRemoval { } async retry(provider: ManagedDataAccountProvider, registered: Set): Promise { - const pendingRoot = join(this.root, provider, '.pending-delete') - if (!existsSync(pendingRoot)) { + const providerRoot = join(this.root, provider) + if (!existsSync(providerRoot)) { return } try { + this.assertOwned(providerRoot) + await this.retryBackups(providerRoot, registered) + const pendingRoot = join(providerRoot, '.pending-delete') + if (!existsSync(pendingRoot)) { + return + } this.assertOwned(pendingRoot) const entries = await readdir(pendingRoot, { withFileTypes: true }) for (const entry of entries) { @@ -129,7 +182,9 @@ export class ManagedDataAccountProfileRemoval { continue } await this.cleanup(join(pendingRoot, entry.name)) - this.discardBackup(join(this.root, provider, `accounts.json.${entry.name}.rollback`)) + if (!existsSync(join(providerRoot, entry.name))) { + this.discardBackup(join(providerRoot, `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.ts b/src/main/managed-data-accounts/service.ts index 180565d23d4d..5ebf2ae91f25 100644 --- a/src/main/managed-data-accounts/service.ts +++ b/src/main/managed-data-accounts/service.ts @@ -47,6 +47,7 @@ export class ManagedDataAccountService { this.profileRemoval = new ManagedDataAccountProfileRemoval( root, (path) => this.assertOwned(path), + (contents) => stateSchema.parse(JSON.parse(contents)), removeDirectory ) if (existsSync(root)) { From 28739cd94e2ba1ee43068e2f0658dd42164e90a4 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 3 Oct 2026 01:10:07 -0700 Subject: [PATCH 23/24] fix(accounts): protect registered UUID case variants during removal recovery Compare validated account UUID identities without changing stored IDs or filesystem paths. Protect registered originals and quarantines, including explicit retry variants, and accept equivalent UUID spelling in a valid removal backup. Retain the complete account component and published contributor ancestry. Private source only; no index, ref, or public mutation. --- .../profile-removal-recovery.test.ts | 73 ++++++++++++++++++- .../managed-data-accounts/profile-removal.ts | 13 +++- 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/src/main/managed-data-accounts/profile-removal-recovery.test.ts b/src/main/managed-data-accounts/profile-removal-recovery.test.ts index f0ccc305d6f1..ec3035ba6344 100644 --- a/src/main/managed-data-accounts/profile-removal-recovery.test.ts +++ b/src/main/managed-data-accounts/profile-removal-recovery.test.ts @@ -34,8 +34,10 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }) }) -function interruptedRemoval(provider: ManagedDataAccountProvider = 'devin') { - const id = randomUUID() +function interruptedRemoval( + provider: ManagedDataAccountProvider = 'devin', + id: string = randomUUID() +) { const providerRoot = join(storage, provider) const directory = join(providerRoot, id) const credentialsPath = join(directory, 'data', 'test-credentials') @@ -193,6 +195,73 @@ describe('interrupted managed account removal recovery', () => { expect(restarted.list('devin')).toEqual(fixture.before) }) + it.each(['lowercase', 'uppercase'] as const)( + 'preserves original credentials registered with a %s UUID spelling', + async (spelling) => { + const lowerId = randomUUID() + const registeredId = spelling === 'lowercase' ? lowerId : lowerId.toUpperCase() + const markerId = spelling === 'lowercase' ? lowerId.toUpperCase() : lowerId + const fixture = interruptedRemoval('devin', markerId) + const registered = { + accounts: [{ ...fixture.before.accounts[0], id: registeredId }], + activeAccountId: registeredId + } + expect(writeSecureFile(fixture.metadataPath, JSON.stringify(registered))).toBe(true) + const marker = readFileSync(fixture.rollbackPath) + const restarted = new ManagedDataAccountService(storage) + await finishStartup(restarted) + expect(readFileSync(fixture.credentialsPath, 'utf8')).toBe('private-test-only-credential') + expect(readFileSync(fixture.rollbackPath)).toEqual(marker) + await expect(restarted.remove('devin', markerId)).rejects.toThrow( + 'Managed account not found.' + ) + expect(readFileSync(fixture.credentialsPath, 'utf8')).toBe('private-test-only-credential') + expect(restarted.list('devin').accounts).toEqual(registered.accounts) + } + ) + + it.each(['lowercase', 'uppercase'] as const)( + 'preserves a quarantine registered with a %s UUID spelling', + async (spelling) => { + const lowerId = randomUUID() + const registeredId = spelling === 'lowercase' ? lowerId : lowerId.toUpperCase() + const markerId = spelling === 'lowercase' ? lowerId.toUpperCase() : lowerId + const fixture = interruptedRemoval('devin', markerId) + const registered = { + accounts: [{ ...fixture.before.accounts[0], id: registeredId }], + activeAccountId: registeredId + } + expect(writeSecureFile(fixture.metadataPath, JSON.stringify(registered))).toBe(true) + const pendingDirectory = join(fixture.providerRoot, '.pending-delete', markerId) + const pendingCredentials = join(pendingDirectory, 'data', 'test-credentials') + mkdirSync(join(fixture.providerRoot, '.pending-delete'), { recursive: true }) + fileSystem.renameSync(fixture.directory, pendingDirectory) + const marker = readFileSync(fixture.rollbackPath) + const restarted = new ManagedDataAccountService(storage) + await finishStartup(restarted) + expect(readFileSync(pendingCredentials, 'utf8')).toBe('private-test-only-credential') + expect(readFileSync(fixture.rollbackPath)).toEqual(marker) + await expect(restarted.remove('devin', markerId)).rejects.toThrow( + 'Managed account not found.' + ) + expect(readFileSync(pendingCredentials, 'utf8')).toBe('private-test-only-credential') + expect(restarted.list('devin').accounts).toEqual(registered.accounts) + } + ) + + it('recovers an unregistered profile when its marker contains another UUID spelling', async () => { + const fixture = interruptedRemoval('devin', randomUUID().toUpperCase()) + const before = { + accounts: [{ ...fixture.before.accounts[0], id: fixture.id.toLowerCase() }], + activeAccountId: fixture.id.toLowerCase() + } + expect(writeSecureFile(fixture.rollbackPath, JSON.stringify(before))).toBe(true) + const restarted = new ManagedDataAccountService(storage) + await finishStartup(restarted) + expect(existsSync(fixture.directory)).toBe(false) + expect(existsSync(fixture.rollbackPath)).toBe(false) + }) + it.each(['invalid JSON', 'invalid state', 'different id', 'directory', 'missing marker'])( 'preserves original credentials without valid removal evidence: %s', async (invalid) => { diff --git a/src/main/managed-data-accounts/profile-removal.ts b/src/main/managed-data-accounts/profile-removal.ts index 14c7c0c4adaf..9cd83379054e 100644 --- a/src/main/managed-data-accounts/profile-removal.ts +++ b/src/main/managed-data-accounts/profile-removal.ts @@ -32,6 +32,9 @@ export class ManagedDataAccountProfileRemoval { const metadataPath = join(this.root, provider, 'accounts.json') const rollbackPath = `${metadataPath}.${accountId}.rollback` if (!state.accounts.some((account) => account.id === accountId)) { + if (state.accounts.some((account) => account.id.toLowerCase() === accountId.toLowerCase())) { + throw new Error('Managed account not found.') + } if (existsSync(directory)) { if (!this.hasRemovalBackup(rollbackPath, accountId)) { throw new Error('Managed account not found.') @@ -104,7 +107,7 @@ export class ManagedDataAccountProfileRemoval { } try { const backup = this.parseState(readFileSync(rollbackPath, 'utf8')) - return backup.accounts.some((account) => account.id === accountId) + return backup.accounts.some((account) => account.id.toLowerCase() === accountId.toLowerCase()) } catch { return false } @@ -118,7 +121,7 @@ export class ManagedDataAccountProfileRemoval { !entry.isFile() || !accountId || !z.uuid().safeParse(accountId).success || - registered.has(accountId) + registered.has(accountId.toLowerCase()) ) { continue } @@ -166,7 +169,9 @@ export class ManagedDataAccountProfileRemoval { } try { this.assertOwned(providerRoot) - await this.retryBackups(providerRoot, registered) + // UUID case variants may name the same directory. + const registeredIds = new Set([...registered].map((id) => id.toLowerCase())) + await this.retryBackups(providerRoot, registeredIds) const pendingRoot = join(providerRoot, '.pending-delete') if (!existsSync(pendingRoot)) { return @@ -177,7 +182,7 @@ export class ManagedDataAccountProfileRemoval { if ( !entry.isDirectory() || !z.uuid().safeParse(entry.name).success || - registered.has(entry.name) + registeredIds.has(entry.name.toLowerCase()) ) { continue } From 70bc58df5eb269d7d7bad9796f8d56d6e5a8af4f Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 3 Oct 2026 02:59:01 -0700 Subject: [PATCH 24/24] Allow cold node-pty setup on Windows ARM CI Keep a ten-minute bound only for node-pty rebuilt on Windows ARM CI hosts; all other node-gyp calls retain five minutes. Cold setup in two completed ARM jobs left compilation less than a minute. --- config/scripts/ensure-native-runtime.mjs | 6 ++-- config/scripts/ensure-native-runtime.test.mjs | 15 ++++++++-- .../windows-process-tree-gyp-rebuild.mjs | 10 +++++++ .../windows-process-tree-gyp-rebuild.test.mjs | 28 +++++++++++++++++++ 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index bee1b3fed9ee..a91b4f528c4f 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -9,6 +9,7 @@ import { ensureWindowsProcessTreeCommandLinePatch, inspectWindowsProcessTreeAddon, nodeGypRebuildInvocation, + nodeGypRebuildTimeoutMs, stageWindowsProcessTreeNodeAddonApiHeaders, windowsProcessTreeAddonPath } from './windows-process-tree-gyp-rebuild.mjs' @@ -405,6 +406,7 @@ function rebuildNodeRuntimeModules(moduleNames) { console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`) // pnpm exec inside an installed addon cannot discover the root build tool. runNodeGyp( + moduleName, nodeGypRebuildInvocation( process.arch, moduleDir, @@ -417,7 +419,7 @@ function rebuildNodeRuntimeModules(moduleNames) { } } -function runNodeGyp({ args, cwd }) { +function runNodeGyp(moduleName, { args, cwd }) { const env = process.platform === 'linux' ? { ...process.env, CXXFLAGS: `${process.env.CXXFLAGS ?? ''} -std=gnu++2a`.trim() } @@ -428,7 +430,7 @@ function runNodeGyp({ args, cwd }) { cwd, env, stdio: 'inherit', - timeoutMs: 300_000 + timeoutMs: nodeGypRebuildTimeoutMs(moduleName) }) if (result.code !== 0) { console.error( diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index 3cd01374d1f3..68a10b5bb206 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -20,7 +20,10 @@ import { resolveCliCommand } from '../../src/shared/node-cli-command-resolution. import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal.ts' import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' import { copyScriptWithLocalModules } from './script-module-dependencies.mjs' -import { nodeGypRebuildInvocation } from './windows-process-tree-gyp-rebuild.mjs' +import { + nodeGypRebuildInvocation, + nodeGypRebuildTimeoutMs +} from './windows-process-tree-gyp-rebuild.mjs' const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url)) // The import walk sees `from './x.mjs'` only, so the createRequire'd CJS @@ -60,6 +63,7 @@ describe('ensure-native-runtime', () => { expect(result.stderr).toContain('node-gyp stderr complete\n') const log = readFileSync(logPath, 'utf8') expect(log).toContain(`node-gyp rebuild --arch=${process.arch}\n`) + expect(log).toContain(`node-gyp timeout=${nodeGypRebuildTimeoutMs('node-pty')}\n`) expect(log).toContain(`trackFileAccess=${process.platform === 'win32' ? 'false' : ''}\n`) expect(log).toContain(join('node_modules', 'node-pty')) if (process.platform === 'linux') { @@ -275,7 +279,14 @@ function mkTempProject() { copyScriptWithLocalModules(sourceScriptPath, join(projectDir, 'config', 'scripts')) writeFileSync( join(projectDir, 'config', 'scripts', 'script-child-process.mjs'), - `export { describeProcessFailure, runProcessSync } from ${JSON.stringify(new URL('./script-child-process.mjs', import.meta.url).href)}\n` + `import { appendFileSync } from 'node:fs' +import { describeProcessFailure, runProcessSync as run } from ${JSON.stringify(new URL('./script-child-process.mjs', import.meta.url).href)} +export { describeProcessFailure } +export function runProcessSync(options) { + appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-gyp timeout=\${options.timeoutMs}\\n\`) + return run(options) +} +` ) for (const name of REQUIRED_CJS_SIBLINGS) { copyFileSync( diff --git a/config/scripts/windows-process-tree-gyp-rebuild.mjs b/config/scripts/windows-process-tree-gyp-rebuild.mjs index c64df18fa899..f782334628d0 100644 --- a/config/scripts/windows-process-tree-gyp-rebuild.mjs +++ b/config/scripts/windows-process-tree-gyp-rebuild.mjs @@ -65,6 +65,16 @@ export function nodeGypRebuildInvocation( } } +export function nodeGypRebuildTimeoutMs( + moduleName, + { platform = process.platform, arch = process.arch, ci = process.env.CI } = {} +) { + // Cold headers and toolchain discovery consumed over four minutes on Windows ARM CI. + return moduleName === 'node-pty' && platform === 'win32' && arch === 'arm64' && ci === 'true' + ? 600_000 + : 300_000 +} + /** The binary the addon actually loads. */ export function windowsProcessTreeAddonPath(packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR) { return join(packageDir, 'build', 'Release', 'windows_process_tree.node') diff --git a/config/scripts/windows-process-tree-gyp-rebuild.test.mjs b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs index 73756af16591..620deba58c4e 100644 --- a/config/scripts/windows-process-tree-gyp-rebuild.test.mjs +++ b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs @@ -16,6 +16,7 @@ import { assertWindowsProcessTreeRuntimeCreationTime, inspectWindowsProcessTreeAddon, nodeGypRebuildInvocation, + nodeGypRebuildTimeoutMs, stageWindowsProcessTreeNodeAddonApiHeaders, WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS, WINDOWS_PROCESS_TREE_PACKAGE_DIR @@ -54,6 +55,33 @@ describe('windows-process-tree node-gyp rebuild', () => { }) }) + it('allows cold setup and compilation only for node-pty on a Windows ARM CI host', () => { + expect( + nodeGypRebuildTimeoutMs('node-pty', { platform: 'win32', arch: 'arm64', ci: 'true' }) + ).toBe(600_000) + }) + + it.each([ + { moduleName: 'node-pty', platform: 'win32', arch: 'x64', ci: 'true' }, + { moduleName: 'node-pty', platform: 'linux', arch: 'arm64', ci: 'true' }, + { moduleName: 'node-pty', platform: 'darwin', arch: 'arm64', ci: 'true' }, + { moduleName: 'node-pty', platform: 'win32', arch: 'arm64', ci: '' }, + { moduleName: 'node-pty', platform: 'win32', arch: 'arm64', ci: 'false' }, + { moduleName: 'node-pty', platform: 'win32', arch: 'arm64', ci: '1' }, + { moduleName: '@orca/windows-registry', platform: 'win32', arch: 'arm64', ci: 'true' }, + { moduleName: '@vscode/windows-process-tree', platform: 'win32', arch: 'arm64', ci: 'true' } + ])('keeps the five-minute bound for $moduleName on $platform/$arch with CI=$ci', (host) => { + expect(nodeGypRebuildTimeoutMs(host.moduleName, host)).toBe(300_000) + }) + + it('uses the execution host rather than an ARM cross-compilation target', () => { + const { args } = nodeGypRebuildInvocation('arm64', import.meta.dirname) + expect(args).toContain('--arch=arm64') + expect( + nodeGypRebuildTimeoutMs('node-pty', { platform: 'win32', arch: 'x64', ci: 'true' }) + ).toBe(300_000) + }) + it('copies node-addon-api headers into the patched include dir', () => { const packageDir = mkdtempSync(join(tmpdir(), 'orca-windows-process-tree-headers-')) try {