diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index da6d45c8a23..fd45c42705f 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -144,8 +144,11 @@ describe('Electron Vite output contract', () => { expect(external('@xterm/addon-serialize', undefined, false)).toBe(false) expect(external('tldts', undefined, false)).toBe(false) expect(external('zod', undefined, false)).toBe(false) + expect(external('smol-toml', undefined, false)).toBe(false) + expect(external('smol-toml/package.json', undefined, false)).toBe(false) expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('tldts') expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod') + expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('smol-toml') }) it('bundles validation dependencies used by the sandboxed preload', () => { diff --git a/config/scripts/managed-data-account-runtime.test.ts b/config/scripts/managed-data-account-runtime.test.ts new file mode 100644 index 00000000000..452629732ac --- /dev/null +++ b/config/scripts/managed-data-account-runtime.test.ts @@ -0,0 +1,106 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { resolveConfig } from 'electron-vite' +import { build } from 'vite' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { runProcess } from '../../src/shared/child-process/run-process' + +const projectDir = resolve(import.meta.dirname, '../..') +const require = createRequire(import.meta.url) +let outputDir: string + +beforeAll(async () => { + outputDir = mkdtempSync(join(tmpdir(), 'orca-account-runtime-')) + const resolved = await resolveConfig( + { configFile: join(projectDir, 'electron.vite.config.ts') }, + 'build', + 'production' + ) + const main = resolved.config?.main + if (!main?.build) { + throw new Error('Expected main-process build config') + } + await build({ + ...main, + logLevel: 'silent', + build: { + ...main.build, + outDir: join(outputDir, 'bundle'), + sourcemap: false, + rollupOptions: { + ...main.build.rollupOptions, + input: join(projectDir, 'src/main/managed-data-accounts/credential-capture.ts'), + output: { format: 'cjs', entryFileNames: 'credential-capture.cjs' } + } + } + }) + mkdirSync(join(outputDir, 'source', 'devin'), { recursive: true }) + writeFileSync( + join(outputDir, 'source', 'devin', 'credentials.toml'), + 'windsurf_api_key = "offline-account-runtime-fixture"\n' + ) +}) + +afterAll(() => { + if (outputDir) { + rmSync(outputDir, { recursive: true, force: true }) + } +}) + +describe('managed account credentials in the production main bundle', () => { + it.each([ + { name: 'Node', program: process.execPath }, + { name: 'Electron', program: require('electron') } + ])( + 'captures Devin credentials under $name outside the dependency install', + async ({ name, program }) => { + const environment: Record = { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + ELECTRON_RUN_AS_NODE: '1' + } + for (const key of [ + 'HOME', + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'XDG_STATE_HOME', + 'XDG_CACHE_HOME' + ]) { + const directory = join(outputDir, name, key) + mkdirSync(directory, { recursive: true }) + environment[key] = directory + } + const script = ` + const assert = require('node:assert/strict') + const { captureDataAccountCredentials } = require(process.argv[1]) + captureDataAccountCredentials('devin', process.argv[2], process.argv[3]) + .then((integrations) => { + assert.deepEqual(integrations, ['devin']) + console.log('Private credentials captured') + }).catch((error) => { console.error(error); process.exitCode = 1 }) + ` + const destination = join(outputDir, name, 'captured') + const result = await runProcess({ + program, + args: [ + '-e', + script, + join(outputDir, 'bundle', 'credential-capture.cjs'), + join(outputDir, 'source'), + destination + ], + cwd: outputDir, + env: environment, + timeoutMs: 20000 + }) + expect(result.code, result.stderr).toBe(0) + expect(result.timedOut).toBe(false) + expect(result.stdout.trim()).toBe('Private credentials captured') + expect(readFileSync(join(destination, 'devin', 'credentials.toml'), 'utf8')).toContain( + 'offline-account-runtime-fixture' + ) + } + ) +}) diff --git a/docs/reference/managed-data-accounts.md b/docs/reference/managed-data-accounts.md new file mode 100644 index 00000000000..7967ab231e5 --- /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 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. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 4f4cedb456c..a899fd97f9f 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -17,6 +17,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/package.json b/package.json index 5b37170106c..3e67dfb4813 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.16", "tweetnacl": "^1.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47cce6aa109..9fb1428002d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,6 +238,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 diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts index bf1f1e313d1..c27ea510826 100644 --- a/src/cli/handler-group-manifest.ts +++ b/src/cli/handler-group-manifest.ts @@ -19,7 +19,7 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [ }, { name: 'account', - keys: ['account add', 'account list'], + keys: ['account add', 'account list', 'account select', 'account remove'], load: async () => (await import('./handlers/account.js')).ACCOUNT_HANDLERS }, { diff --git a/src/cli/handlers/account-list-format.ts b/src/cli/handlers/account-list-format.ts new file mode 100644 index 00000000000..66da6d4b1b7 --- /dev/null +++ b/src/cli/handlers/account-list-format.ts @@ -0,0 +1,41 @@ +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 { + 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)' : ''}` + ) + .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.test.ts b/src/cli/handlers/account.test.ts index 9cecb6f12e4..8b2fe9d5ae7 100644 --- a/src/cli/handlers/account.test.ts +++ b/src/cli/handlers/account.test.ts @@ -715,7 +715,9 @@ describe('account CLI handlers', () => { // default would run a full OAuth login for the wrong provider. await expect( ACCOUNT_HANDLERS['account add']({ ...context('claude'), flags: new Map([['agent', true]]) }) - ).rejects.toThrow('Missing a value for --agent') + ).rejects.toThrow( + 'Missing a value for --agent. Use `--agent claude`, `--agent codex`, `--agent opencode`, or `--agent devin`.' + ) expect(spawnMock).not.toHaveBeenCalled() }) diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index b2a06c6d2e2..19d7cac1b49 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 @@ -297,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') @@ -306,17 +284,21 @@ 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' - if (agent !== 'claude' && agent !== 'codex') { + if (agent !== 'claude' && agent !== 'codex' && agent !== 'opencode' && agent !== 'devin') { throw new RuntimeClientError( 'invalid_argument', - `Unsupported --agent "${agent}". Use "claude" or "codex".` + `Unsupported --agent "${agent}". Use "claude", "codex", "opencode", or "devin".` ) } rejectAccountRemoteSelectionFlags(ctx, 'orca account add') + if (agent === 'opencode' || agent === 'devin') { + await addDataAccount(ctx, agent, runAgentLoginInTerminal) + return + } // Why: fail on runtime version skew before burning a full OAuth round trip. await assertAccountImportSupported(ctx) await ctx.client.call('accounts.list', { refreshUsage: false }) @@ -324,17 +306,32 @@ export const ACCOUNT_HANDLERS: Record = { }, 'account list': async (ctx) => { rejectAccountRemoteSelectionFlags(ctx, 'orca account list') + const provider = ctx.flags.get('agent') + if (provider !== undefined) { + await listDataAccounts(ctx, provider) + return + } const { client, json } = ctx // Why: this command renders no usage numbers, so skip the forced provider // refresh — it is one serial network round-trip per managed account. const result = await client.call('accounts.list', { refreshUsage: false }) - printResult( - result, - json, - (snapshot) => - `${formatAccountsBlock('Claude', snapshot.claude)}\n\n${formatAccountsBlock('Codex', snapshot.codex)}` + printResult(result, json, (snapshot) => + [ + formatAccountsBlock('Claude', snapshot.claude), + formatAccountsBlock('Codex', snapshot.codex), + ...(snapshot.opencode ? [formatDataAccounts('OpenCode', snapshot.opencode)] : []), + ...(snapshot.devin ? [formatDataAccounts('Devin', snapshot.devin)] : []) + ].join('\n\n') ) + }, + 'account select': async (ctx) => { + rejectAccountRemoteSelectionFlags(ctx, 'orca account select') + await mutateDataAccount(ctx, 'select') + }, + 'account remove': async (ctx) => { + rejectAccountRemoteSelectionFlags(ctx, 'orca account remove') + await mutateDataAccount(ctx, 'remove') } } diff --git a/src/cli/handlers/data-account-commands.test.ts b/src/cli/handlers/data-account-commands.test.ts new file mode 100644 index 00000000000..6dd4b2e2362 --- /dev/null +++ b/src/cli/handlers/data-account-commands.test.ts @@ -0,0 +1,85 @@ +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, listDataAccounts } 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.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', + ok: true, + result: { capabilities: [] }, + _meta: { runtimeId: 'test' } + }) + const login = vi.fn() + await expect(addDataAccount(context, 'opencode', login)).rejects.toThrow('Update or restart') + expect(login).not.toHaveBeenCalled() + }) + + it('isolates official login and removes credentials after failed capture', async () => { + const call = vi.spyOn(client, 'call') + call + .mockResolvedValueOnce({ + id: 'test', + ok: true, + result: { capabilities: [DATA_ACCOUNT_RUNTIME_CAPABILITY] }, + _meta: { runtimeId: 'test' } + }) + .mockRejectedValueOnce(new Error('capture failed')) + let directory = '' + const login = vi.fn(async (command: string, args: string[], env: Record) => { + expect(command).toBe('opencode') + expect(args).toEqual(['auth', 'login', 'opencode-go', '--standalone']) + directory = dirname(env.XDG_DATA_HOME) + expect(env.XDG_STATE_HOME).toBe(join(directory, 'state')) + expect(env.OPENCODE_AUTH_CONTENT).toBe('') + expect(env.OPENCODE_DB).toBe('opencode.db') + expect(existsSync(directory)).toBe(true) + }) + await expect(addDataAccount(context, 'opencode', login)).rejects.toThrow('capture failed') + expect(call).toHaveBeenLastCalledWith('accounts.addDataFromHome', { + provider: 'opencode', + sourceDataHome: join(directory, 'data'), + label: 'opencode' + }) + expect(existsSync(directory)).toBe(false) + }) +}) diff --git a/src/cli/handlers/data-account-commands.ts b/src/cli/handlers/data-account-commands.ts new file mode 100644 index 00000000000..0642992238f --- /dev/null +++ b/src/cli/handlers/data-account-commands.ts @@ -0,0 +1,151 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { HandlerContext } from '../dispatch' +import { printResult } from '../format' +import { RuntimeClientError } from '../runtime-client' +import { DATA_ACCOUNT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import type { RuntimeStatus } from '../../shared/runtime-types' +import type { + ManagedDataAccountProvider, + ManagedDataAccountsState +} from '../../shared/managed-account-types' +import { + withInteractiveLoginCleanup, + type InteractiveLoginSession +} from './interactive-login-interruption' +import { getWslAccountTarget } from './account-wsl-location' +import { formatDataAccounts } from './account-list-format' + +export async function assertDataAccountsSupported(ctx: HandlerContext): Promise { + const status = await ctx.client.call('status.get') + if (!status.result.capabilities?.includes(DATA_ACCOUNT_RUNTIME_CAPABILITY)) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'Update or restart this Orca host to manage OpenCode and Devin accounts.' + ) + } +} + +export async function addDataAccount( + ctx: HandlerContext, + provider: ManagedDataAccountProvider, + login: ( + command: string, + args: string[], + extraEnv: Record, + json: boolean, + session: InteractiveLoginSession + ) => Promise +): Promise { + if (getWslAccountTarget(ctx.cwd)?.runtime === 'wsl') { + throw new RuntimeClientError( + 'invalid_argument', + 'Run this command inside the WSL host runtime; Windows-hosted WSL account import is not supported.' + ) + } + const label = ctx.flags.get('label') ?? provider + if (typeof label !== 'string' || !label.trim() || label.trim().length > 120) { + throw new RuntimeClientError('invalid_argument', '--label must contain 1–120 characters.') + } + await assertDataAccountsSupported(ctx) + const integration = ctx.flags.get('integration') + if ( + integration !== undefined && + (provider !== 'opencode' || typeof integration !== 'string' || !integration.trim()) + ) { + throw new RuntimeClientError( + 'invalid_argument', + '--integration requires an OpenCode integration ID or name.' + ) + } + const directory = mkdtempSync(join(tmpdir(), `orca-account-add-${provider}-`)) + const session: InteractiveLoginSession = { + child: null, + registering: false, + terminationPromise: null + } + const result = await withInteractiveLoginCleanup( + session, + async () => { + rmSync(directory, { recursive: true, force: true }) + }, + async () => { + const dataHome = join(directory, 'data') + await login( + provider, + provider === 'opencode' + ? [ + 'auth', + 'login', + ...(typeof integration === 'string' ? [integration] : []), + '--standalone' + ] + : ['auth', 'login', '--force-manual-token-flow'], + { + XDG_DATA_HOME: dataHome, + XDG_CONFIG_HOME: join(directory, 'config'), + XDG_CACHE_HOME: join(directory, 'cache'), + XDG_STATE_HOME: join(directory, 'state'), + ...(provider === 'opencode' + ? { + OPENCODE_CONFIG_DIR: join(directory, 'config', 'opencode'), + OPENCODE_AUTH_CONTENT: '', + OPENCODE_DB: 'opencode.db' + } + : {}) + }, + ctx.json, + session + ) + session.registering = true + return ctx.client.call('accounts.addDataFromHome', { + provider, + sourceDataHome: dataHome, + label: label.trim() + }) + } + ) + printResult(result, ctx.json, (state) => formatDataAccounts(provider, state)) +} + +export async function listDataAccounts(ctx: HandlerContext, provider: unknown): Promise { + if (provider !== 'opencode' && provider !== 'devin') { + throw new RuntimeClientError('invalid_argument', 'Use --agent opencode or --agent devin.') + } + await assertDataAccountsSupported(ctx) + const result = + await ctx.client.call>>( + 'accounts.listData' + ) + printResult(result, ctx.json, (snapshot) => { + const state = snapshot[provider] + if (!state) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'Managed accounts are unavailable on this host.' + ) + } + return formatDataAccounts(provider, state) + }) +} + +export async function mutateDataAccount( + ctx: HandlerContext, + action: 'select' | 'remove' +): Promise { + const provider = ctx.flags.get('agent') + const id = ctx.flags.get('account') + if ((provider !== 'opencode' && provider !== 'devin') || typeof id !== 'string' || !id) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use --agent opencode|devin and --account (system for the default selection).' + ) + } + await assertDataAccountsSupported(ctx) + const result = await ctx.client.call(`accounts.${action}Data`, { + provider, + accountId: action === 'select' && id === 'system' ? null : id + }) + printResult(result, ctx.json, (state) => formatDataAccounts(provider, state)) +} diff --git a/src/cli/help.ts b/src/cli/help.ts index 8fc3b073faf..eafe9753271 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -178,7 +178,10 @@ 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 (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/index.test.ts b/src/cli/index.test.ts index 2a6c014b984..259fce08cca 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -443,10 +443,10 @@ describe('orca root help', () => { await main([], '/tmp/repo') expect(logSpy.mock.calls.flat().join('\n')).toContain( - 'account add Add a managed Claude or Codex account on this Orca host' + 'account add Add a managed agent account on this Orca host' ) expect(logSpy.mock.calls.flat().join('\n')).toContain( - 'account list List managed Claude and Codex accounts on this Orca host' + 'account list List managed agent accounts on this Orca host' ) logSpy.mockRestore() }) diff --git a/src/cli/root-help-text-primary.ts b/src/cli/root-help-text-primary.ts index ac2a322fd86..6d6a73537e2 100644 --- a/src/cli/root-help-text-primary.ts +++ b/src/cli/root-help-text-primary.ts @@ -18,8 +18,10 @@ export const ROOT_HELP_TEXT_PRIMARY = [ ' search Search the full text of agent sessions on one Orca host', '', 'Accounts:', - ' account add Add a managed Claude or Codex account on this Orca host', - ' account list List managed Claude and Codex accounts on this Orca host', + ' account add Add a managed agent account on this Orca host', + ' account list List managed agent accounts on this Orca host', + ' account select Select an OpenCode or Devin account for new launches', + ' account remove Remove an OpenCode or Devin account and its private data', '', 'Skills:', ' skills installed List installed skill selectors', diff --git a/src/cli/specs/account.test.ts b/src/cli/specs/account.test.ts index 7e285cf3b97..1ab5d61b116 100644 --- a/src/cli/specs/account.test.ts +++ b/src/cli/specs/account.test.ts @@ -33,8 +33,9 @@ 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') + expect(spec('account add').usage).toContain('[--integration ]') }) it('aligns the --agent description with the global flag descriptions', () => { @@ -50,4 +51,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') + } + }) }) diff --git a/src/cli/specs/account.ts b/src/cli/specs/account.ts index 69dd0896e8d..0411bdbd1d0 100644 --- a/src/cli/specs/account.ts +++ b/src/cli/specs/account.ts @@ -8,12 +8,16 @@ 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 ] [--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.', '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 +25,27 @@ export const ACCOUNT_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['account', 'list'], - summary: 'List managed Claude and Codex accounts on this Orca host', - usage: 'orca account list [--json]', - allowedFlags: [...GLOBAL_FLAGS], + summary: 'List managed agent accounts on this Orca host', + usage: 'orca account list [--agent opencode|devin] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'agent'], notes: [ 'Lists the accounts on this machine. `--environment` / `--pairing-code` are rejected rather than ignored; run it on the host whose accounts you want to see.' ], examples: ['orca account list'] + }, + { + path: ['account', 'select'], + summary: 'Select an OpenCode or Devin profile for new agent launches', + usage: 'orca account select --agent opencode|devin --account [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'agent', 'account'] + }, + { + path: ['account', 'remove'], + summary: 'Remove a managed OpenCode or Devin profile and its private data', + usage: 'orca account remove --agent opencode|devin --account [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'agent', 'account'], + notes: [ + 'Deletes credentials and conversation data in the managed profile. Stop its running agents first. System credentials are never removed.' + ] } ] diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index bfbd9b420e2..2d161a01289 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -33,6 +33,14 @@ __orca_restore_agent_teams_path # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" +if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then + export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}" + export XDG_STATE_HOME="${ORCA_DATA_ACCOUNT_STATE_HOME}" + if [[ "${ORCA_DATA_ACCOUNT_PROVIDER:-}" == opencode ]]; then + export OPENCODE_AUTH_CONTENT="" + export OPENCODE_DB="opencode.db" + fi +fi [[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" # Why: OMP does not auto-load Orca's managed status extension; wrap only # interactive launch invocations so subcommands such as `omp config` keep diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 12d37e0f724..2187aaca0f0 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -71,6 +71,14 @@ __orca_deferred_init() { } __orca_restore_agent_teams_path [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" + if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then + export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}" + export XDG_STATE_HOME="${ORCA_DATA_ACCOUNT_STATE_HOME}" + if [[ "${ORCA_DATA_ACCOUNT_PROVIDER:-}" == opencode ]]; then + export OPENCODE_AUTH_CONTENT="" + export OPENCODE_DB="opencode.db" + fi + fi [[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" # Why: OMP does not auto-load Orca's managed status extension; wrap only # interactive launch invocations so subcommands such as `omp config` keep diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index d947f956e48..fd151e03b12 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -43,6 +43,14 @@ fi # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" +if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then + export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}" + export XDG_STATE_HOME="${ORCA_DATA_ACCOUNT_STATE_HOME}" + if [[ "${ORCA_DATA_ACCOUNT_PROVIDER:-}" == opencode ]]; then + export OPENCODE_AUTH_CONTENT="" + export OPENCODE_DB="opencode.db" + fi +fi [[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" # Why: OMP does not auto-load Orca's managed status extension; wrap only # interactive launch invocations so subcommands such as `omp config` keep diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 93fb79a907a..7172b28d2dc 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -78,6 +78,14 @@ __orca_deferred_init() { } __orca_restore_agent_teams_path [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" + if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then + export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}" + export XDG_STATE_HOME="${ORCA_DATA_ACCOUNT_STATE_HOME}" + if [[ "${ORCA_DATA_ACCOUNT_PROVIDER:-}" == opencode ]]; then + export OPENCODE_AUTH_CONTENT="" + export OPENCODE_DB="opencode.db" + fi + fi [[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" # Why: OMP does not auto-load Orca's managed status extension; wrap only # interactive launch invocations so subcommands such as `omp config` keep diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 4299ebb42b5..102534564b7 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -44,6 +44,14 @@ __orca_deferred_init() { if __orca_has_feature overlay; then # Why: remote startup files can re-export user defaults after relay spawn. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" + if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then + export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}" + export XDG_STATE_HOME="${ORCA_DATA_ACCOUNT_STATE_HOME}" + if [[ "${ORCA_DATA_ACCOUNT_PROVIDER:-}" == opencode ]]; then + export OPENCODE_AUTH_CONTENT="" + export OPENCODE_DB="opencode.db" + fi + fi [[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" [[ -n "${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac # Why: OMP does not auto-load Orca's managed status extension; wrap only diff --git a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts index 76f05e9ddf5..6ae5c7f4c63 100644 --- a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts +++ b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts @@ -1,4 +1,5 @@ import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' +import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell' import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function' import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' import { BASH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK } from '../shell-templates' @@ -37,6 +38,7 @@ __orca_restore_agent_teams_path # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +${MANAGED_DATA_ACCOUNT_POSIX_RESTORE} [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts index aad4b7ec5e4..919583b541c 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/fish-xdg-data-dirs-handoff.ts b/src/main/fish-xdg-data-dirs-handoff.ts index e5be3ce6ab8..f189f37128b 100644 --- a/src/main/fish-xdg-data-dirs-handoff.ts +++ b/src/main/fish-xdg-data-dirs-handoff.ts @@ -4,6 +4,7 @@ * that dir's fish/vendor_conf.d, and the snippet's first act is to undo it. */ import { getFishCodexShellLaunchPreflight } from '../shared/codex-shell-function' +import { MANAGED_DATA_ACCOUNT_FISH_RESTORE } from '../shared/managed-data-account-shell' import type { ShellWrapperFile } from './shell-wrapper-file-writer' /** Exactly what Orca prepended, so the snippet can remove that and nothing else. */ @@ -69,6 +70,7 @@ function __orca_fish_xdg_handoff status is-interactive; or return 0 function __orca_define_codex --on-event fish_prompt functions -e __orca_define_codex +${MANAGED_DATA_ACCOUNT_FISH_RESTORE} ${getFishCodexShellLaunchPreflight()} end end diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 324dbe60dc7..1cae20bfcbd 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -34,6 +34,7 @@ import { restoreOrStripOverlayEnv } from './pi-agent' import { AGENT_HOOK_RUNTIME_ENV_KEYS } from './spawn-env-keys' +import { applyManagedDataAccountEnvironment } from '../../../managed-data-accounts/launch-environment' /** * Mutates `baseEnv` in place with all host-local PTY env vars and returns it. @@ -69,6 +70,7 @@ export function buildPtyHostEnv( ? undefined : resolvedOpenCodeConfigDir const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) + applyManagedDataAccountEnvironment(baseEnv, { ...opts, launchCommand: launchCommandHint }) const openCodeAgent = selectOpenCodeHookAgent( opts.launchAgent, launchCommandHint, diff --git a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts index 8a9114eb942..a52a9d70360 100644 --- a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts +++ b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts @@ -12,7 +12,7 @@ import type { BuildPtyHostEnvOptions } from './types' const fixture = vi.hoisted(() => ({ userData: '', guestOverlay: '' })) vi.mock('../../../../shared/app-environment', () => ({ - getAppEnvironment: () => ({ getPath: () => fixture.userData }) + getAppEnvironment: () => ({ getPath: () => fixture.userData, onWillQuit: vi.fn() }) })) vi.mock('../../../agent-hooks/server', () => ({ agentHookServer: { buildPtyEnv: () => ({ ORCA_AGENT_HOOK_PORT: '12345' }) } diff --git a/src/main/ipc/pty/managed-data-account-spawn-environment.test.ts b/src/main/ipc/pty/managed-data-account-spawn-environment.test.ts new file mode 100644 index 00000000000..ed13aa02de9 --- /dev/null +++ b/src/main/ipc/pty/managed-data-account-spawn-environment.test.ts @@ -0,0 +1,179 @@ +import { randomUUID, createHash } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { createDaemonPtyEnvironment } from '../../daemon/pty-subprocess/spawn-environment' +import { applyManagedDataAccountEnvironment } from '../../managed-data-accounts/launch-environment' +import { buildPtyIpcSpawnOptions } from './ipc/spawn-options' +import { createPtyIpcSpawnState } from './ipc/spawn-state' +import type { PtySpawnIpcDeps } from './ipc/spawn-types' +import { buildRuntimePtySpawnOptions } from './runtime/spawn-options' +import { createRuntimePtySpawnState } from './runtime/spawn-state' +import type { PtyRuntimeControllerDeps } from './runtime/controller-deps' +import type * as ServiceModule from '../../managed-data-accounts/service' +import { + MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS, + restoreManagedDataAccountEnvironment +} from '../../../shared/managed-data-account-environment' + +const selected = vi.hoisted(() => { + const value: Record = {} + return { value } +}) +vi.mock('../../managed-data-accounts/service', async (importOriginal) => { + const actual = await importOriginal() + const service = new actual.ManagedDataAccountService('test-managed-root') + vi.spyOn(service, 'launchEnvironment').mockImplementation(() => selected.value) + return { ...actual, getManagedDataAccountService: () => service } +}) + +beforeEach(() => { + for (const key of [ + ...MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS, + 'ORCA_DATA_ACCOUNT_DATA_HOME', + 'ORCA_DATA_ACCOUNT_STATE_HOME', + 'ORCA_DATA_ACCOUNT_PROVIDER', + 'ORCA_DATA_ACCOUNT_ORIGINAL_ENV' + ]) { + vi.stubEnv(key, undefined) + } + selected.value = {} +}) +afterEach(() => vi.unstubAllEnvs()) + +function hash(value: string | undefined): string | undefined { + return value === undefined ? undefined : createHash('sha256').update(value).digest('hex') +} + +async function spawnDeletions( + route: string, + env: Record, + envToDelete?: string[] +): Promise { + const args = { cols: 80, rows: 24, envToDelete } + if (route === 'renderer') { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: option construction with no workspace reads no required dependency methods. + const ctx = createPtyIpcSpawnState({} as PtySpawnIpcDeps, args) + ctx.env = env + ctx.isDaemonHostSpawn = true + await buildPtyIpcSpawnOptions(ctx) + ctx.finishTerminalInstall() + return ctx.spawnOptions.envToDelete ?? [] + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: option construction with no workspace reads no required dependency methods. + const ctx = createRuntimePtySpawnState({} as PtyRuntimeControllerDeps, args) + ctx.env = env + ctx.isDaemonHostSpawn = true + await buildRuntimePtySpawnOptions(ctx) + ctx.finishTerminalInstall() + return ctx.spawnOptions.envToDelete ?? [] +} + +describe.each(['renderer', 'runtime'])('%s managed account daemon environment', (route) => { + it('honors explicit user deletions and preserves unknown daemon metadata', async () => { + const env: Record = {} + const envToDelete = await spawnDeletions(route, env, ['XDG_STATE_HOME']) + const inherited = { + XDG_DATA_HOME: join(tmpdir(), 'daemon-data'), + XDG_STATE_HOME: join(tmpdir(), 'daemon-state'), + OPENCODE_DB: 'daemon.db', + ORCA_DATA_ACCOUNT_DATA_HOME: join(tmpdir(), 'future-profile'), + ORCA_DATA_ACCOUNT_PROVIDER: 'future-provider', + ORCA_DATA_ACCOUNT_ORIGINAL_ENV: '{"future":"opaque"}', + ORCA_DATA_ACCOUNT_FUTURE_METADATA: 'opaque-metadata' + } + for (const [key, value] of Object.entries(inherited)) { + vi.stubEnv(key, value) + } + const child = createDaemonPtyEnvironment({ + sessionId: 'pane', + cols: 80, + rows: 24, + env, + envToDelete + }) + expect(child.XDG_STATE_HOME).toBeUndefined() + for (const [key, value] of Object.entries(inherited)) { + if (key !== 'XDG_STATE_HOME') { + expect(child[key]).toBe(value) + } + } + }) + + it('preserves independent daemon defaults when main has no baseline or owned markers', async () => { + const env: Record = {} + applyManagedDataAccountEnvironment(env, { launchAgent: 'opencode' }) + const envToDelete = await spawnDeletions(route, env) + const defaults = { + XDG_DATA_HOME: join(tmpdir(), 'persistent-daemon', 'data'), + XDG_STATE_HOME: join(tmpdir(), 'persistent-daemon', 'state'), + OPENCODE_DB: 'daemon.db', + OPENCODE_AUTH_CONTENT: JSON.stringify({ fixture: { key: randomUUID() } }) + } + for (const [key, value] of Object.entries(defaults)) { + vi.stubEnv(key, value) + } + const child = createDaemonPtyEnvironment({ + sessionId: 'pane', + cols: 80, + rows: 24, + env, + envToDelete + }) + for (const [key, value] of Object.entries(defaults)) { + expect(hash(child[key]) === hash(value)).toBe(true) + } + }) + + it('keeps inline System authentication out of every selected child marker', async () => { + const secret = randomUUID() + const baseline = JSON.stringify({ fixture: { key: secret } }) + vi.stubEnv('OPENCODE_AUTH_CONTENT', baseline) + selected.value = { + XDG_DATA_HOME: join(tmpdir(), 'selected-account', 'data'), + XDG_STATE_HOME: join(tmpdir(), 'selected-account', 'state'), + OPENCODE_AUTH_CONTENT: '', + OPENCODE_DB: 'opencode.db' + } + const env: Record = {} + applyManagedDataAccountEnvironment(env, { launchAgent: 'opencode' }) + const envToDelete = await spawnDeletions(route, env) + const child = createDaemonPtyEnvironment({ + sessionId: 'pane', + cols: 80, + rows: 24, + env, + envToDelete + }) + expect(child.OPENCODE_AUTH_CONTENT).toBe('') + expect(Object.values(child).some((value) => value.includes(secret))).toBe(false) + selected.value = {} + vi.stubEnv('OPENCODE_AUTH_CONTENT', undefined) + const copied = { ...child } + applyManagedDataAccountEnvironment(copied, { launchAgent: 'opencode' }) + expect(hash(copied.OPENCODE_AUTH_CONTENT) === hash(baseline)).toBe(true) + for (const key of MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS) { + vi.stubEnv(key, child[key]) + } + for (const key of [ + 'ORCA_DATA_ACCOUNT_DATA_HOME', + 'ORCA_DATA_ACCOUNT_STATE_HOME', + 'ORCA_DATA_ACCOUNT_PROVIDER', + 'ORCA_DATA_ACCOUNT_ORIGINAL_ENV' + ]) { + vi.stubEnv(key, child[key]) + } + const systemDeletions = await spawnDeletions(route, copied) + const systemChild = createDaemonPtyEnvironment({ + sessionId: 'pane', + cols: 80, + rows: 24, + env: copied, + envToDelete: systemDeletions + }) + expect(hash(systemChild.OPENCODE_AUTH_CONTENT) === hash(baseline)).toBe(true) + expect(systemChild.ORCA_DATA_ACCOUNT_DATA_HOME).toBeUndefined() + restoreManagedDataAccountEnvironment(copied) + expect(hash(copied.OPENCODE_AUTH_CONTENT) === hash(baseline)).toBe(true) + }) +}) diff --git a/src/main/managed-data-accounts/credential-capture.ts b/src/main/managed-data-accounts/credential-capture.ts new file mode 100644 index 00000000000..dd077d4aa05 --- /dev/null +++ b/src/main/managed-data-accounts/credential-capture.ts @@ -0,0 +1,140 @@ +import { lstatSync, readFileSync, rmSync } from 'node:fs' +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' + +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.') + } +} + +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, + 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 + }) + const destination = join(destinationDataHome, 'opencode', 'opencode.db') + let snapshotCreated = false + try { + auditOpenCodeCredentials(database) + snapshotCreated = true + if (!writeSecureFile(destination, '')) { + throw new Error('Could not restrict OpenCode credential file permissions.') + } + await database.backup(destination) + // 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/launch-environment.test.ts b/src/main/managed-data-accounts/launch-environment.test.ts new file mode 100644 index 00000000000..be6be25b175 --- /dev/null +++ b/src/main/managed-data-accounts/launch-environment.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +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', 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 = { + XDG_DATA_HOME: join(tmpdir(), 'user-data'), + OPENCODE_DB: 'user.db', + OPENCODE_AUTH_CONTENT: 'user-config' + } + 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'), + 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() + }) + + 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 new file mode 100644 index 00000000000..51a44e64e26 --- /dev/null +++ b/src/main/managed-data-accounts/launch-environment.ts @@ -0,0 +1,51 @@ +import { + getCommandTokenPathBasename, + getFirstCommandToken +} from '../../shared/command-token-scanner' +import type { TuiAgent } from '../../shared/tui-agent' +import { getManagedDataAccountService } from './service' +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 { + const service = getManagedDataAccountService() + service.restoreOriginalEnvironment(environment) + const inherited = { ...process.env } + service.restoreOriginalEnvironment(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 + } + 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 = service.launchEnvironment(provider) + if (!selected.XDG_DATA_HOME) { + return + } + 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 + 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/original-environment.test.ts b/src/main/managed-data-accounts/original-environment.test.ts new file mode 100644 index 00000000000..dbe1ed1ad94 --- /dev/null +++ b/src/main/managed-data-accounts/original-environment.test.ts @@ -0,0 +1,231 @@ +import { createHash } from 'node:crypto' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { describe, expect, it } from 'vitest' +import { ManagedDataAccountService, getManagedDataAccountService } from './service' +import { getAppEnvironment, setAppEnvironment } from '../../shared/app-environment' +import { restoreManagedDataAccountEnvironment } from '../../shared/managed-data-account-environment' + +const selected = { + XDG_DATA_HOME: join(tmpdir(), 'managed-original', 'data'), + XDG_STATE_HOME: join(tmpdir(), 'managed-original', 'state'), + OPENCODE_AUTH_CONTENT: '', + OPENCODE_DB: 'opencode.db' +} + +function hash(value: string | undefined): string | undefined { + return value === undefined ? undefined : createHash('sha256').update(value).digest('hex') +} + +function managedContext( + service: ManagedDataAccountService, + inline: string, + profile = selected +): Record { + const env: Record = { + XDG_DATA_HOME: join(tmpdir(), 'system-original', 'data'), + OPENCODE_AUTH_CONTENT: inline, + OPENCODE_DB: 'system.db' + } + service.captureOriginalEnvironment(env, profile) + return { + ...env, + ...profile, + ORCA_DATA_ACCOUNT_DATA_HOME: profile.XDG_DATA_HOME, + ORCA_DATA_ACCOUNT_STATE_HOME: profile.XDG_STATE_HOME, + ORCA_DATA_ACCOUNT_PROVIDER: 'opencode' + } +} + +describe('host-private managed account originals', () => { + it('restores multiple copied baselines without exposing either in a marker', () => { + const service = new ManagedDataAccountService('private-original-root') + const first = managedContext(service, 'first-inline-baseline') + const second = managedContext(service, 'second-inline-baseline') + for (const [env, inline] of [ + [first, 'first-inline-baseline'], + [second, 'second-inline-baseline'] + ] as const) { + expect(Object.values(env).some((value) => value.includes(inline))).toBe(false) + const copy = { ...env } + service.restoreOriginalEnvironment(copy) + expect(hash(copy.OPENCODE_AUTH_CONTENT) === hash(inline)).toBe(true) + expect(copy.XDG_DATA_HOME).toBe(join(tmpdir(), 'system-original', 'data')) + expect(copy.XDG_STATE_HOME).toBeUndefined() + expect(copy.OPENCODE_DB).toBe('system.db') + expect(copy.ORCA_DATA_ACCOUNT_ORIGINAL_ENV).toBeUndefined() + } + }) + + it.each([ + { XDG_DATA_HOME: join(tmpdir(), 'independent-data') }, + { XDG_STATE_HOME: join(tmpdir(), 'independent-state') }, + { ORCA_DATA_ACCOUNT_DATA_HOME: join(tmpdir(), 'foreign-data') }, + { ORCA_DATA_ACCOUNT_STATE_HOME: join(tmpdir(), 'foreign-state') }, + { ORCA_DATA_ACCOUNT_PROVIDER: 'devin' }, + { OPENCODE_DB: 'independent.db' }, + { OPENCODE_AUTH_CONTENT: 'independent-inline' } + ])('requires the recorded selection, beyond a caller-supplied reference: %o', (override) => { + const service = new ManagedDataAccountService('private-original-root') + const env = { ...managedContext(service, 'private-baseline'), ...override } + service.restoreOriginalEnvironment(env) + expect(Object.values(env).some((value) => value.includes('private-baseline'))).toBe(false) + for (const [key, value] of Object.entries(override)) { + if (!key.startsWith('ORCA_DATA_ACCOUNT_')) { + expect(env[key]).toBe(value) + } + } + }) + + it('does not resolve a reference attached to a different otherwise valid profile', () => { + const service = new ManagedDataAccountService('private-original-root') + const captured = managedContext(service, 'private-baseline') + const foreign = managedContext(service, 'other-baseline', { + ...selected, + XDG_DATA_HOME: join(tmpdir(), 'different-profile', 'data'), + XDG_STATE_HOME: join(tmpdir(), 'different-profile', 'state') + }) + foreign.ORCA_DATA_ACCOUNT_ORIGINAL_ENV = captured.ORCA_DATA_ACCOUNT_ORIGINAL_ENV + service.restoreOriginalEnvironment(foreign) + expect(foreign.OPENCODE_AUTH_CONTENT).toBeUndefined() + }) + + it('does not resolve private references after service restart or explicit disposal', () => { + const service = new ManagedDataAccountService('private-original-root') + const captured = managedContext(service, 'private-baseline') + const restarted = new ManagedDataAccountService('private-original-root') + const foreign = { ...captured } + restarted.restoreOriginalEnvironment(foreign) + expect(foreign.OPENCODE_AUTH_CONTENT).toBeUndefined() + service.clearInlineAuthBaselines() + service.restoreOriginalEnvironment(captured) + expect(captured.OPENCODE_AUTH_CONTENT).toBeUndefined() + }) + + it('disposes sensitive originals when the host userData root changes or shuts down', () => { + const original = getAppEnvironment() + let root = join(tmpdir(), 'original-host-one') + let shutdown: (() => void) | undefined + try { + setAppEnvironment({ + ...original, + onWillQuit: (handler) => { + shutdown = handler + }, + getPath: (name) => (name === 'userData' ? root : original.getPath(name)) + }) + const first = getManagedDataAccountService() + const captured = managedContext(first, 'private-baseline') + root = join(tmpdir(), 'original-host-two') + const second = getManagedDataAccountService() + const copy = { ...captured } + second.restoreOriginalEnvironment(copy) + first.restoreOriginalEnvironment(captured) + expect(copy.OPENCODE_AUTH_CONTENT).toBeUndefined() + expect(captured.OPENCODE_AUTH_CONTENT).toBeUndefined() + const current = managedContext(second, 'current-baseline') + expect(shutdown).toBeDefined() + shutdown?.() + second.restoreOriginalEnvironment(current) + expect(current.OPENCODE_AUTH_CONTENT).toBeUndefined() + } finally { + setAppEnvironment(original) + } + }) + + it('deduplicates originals and refuses the 65th distinct value before changing its marker', () => { + const service = new ManagedDataAccountService('private-original-root') + const first = managedContext(service, 'baseline-0') + for (let i = 0; i < 100; i++) { + expect(managedContext(service, 'baseline-0').ORCA_DATA_ACCOUNT_ORIGINAL_ENV).toBe( + first.ORCA_DATA_ACCOUNT_ORIGINAL_ENV + ) + } + for (let i = 1; i < 64; i++) { + managedContext(service, `baseline-${i}`) + } + const overflow = { OPENCODE_AUTH_CONTENT: 'overflow-baseline' } + const before = hash(JSON.stringify(overflow)) + expect(() => service.captureOriginalEnvironment(overflow, selected)).toThrow('baseline limit') + expect(hash(JSON.stringify(overflow)) === before).toBe(true) + service.restoreOriginalEnvironment(first) + expect(hash(first.OPENCODE_AUTH_CONTENT) === hash('baseline-0')).toBe(true) + }) + + it('bounds both UTF-8 bytes and selection bindings without evicting originals', () => { + const service = new ManagedDataAccountService('private-original-root') + const oversized = { OPENCODE_AUTH_CONTENT: '€'.repeat(22_000) } + expect(() => service.captureOriginalEnvironment(oversized, selected)).toThrow('launch limit') + expect(Object.keys(oversized)).toEqual(['OPENCODE_AUTH_CONTENT']) + const first = managedContext(service, 'same-baseline') + for (let i = 1; i < 64; i++) { + managedContext(service, 'same-baseline', { + ...selected, + XDG_DATA_HOME: join(tmpdir(), `profile-${i}`, 'data') + }) + } + expect(() => + managedContext(service, 'same-baseline', { + ...selected, + XDG_DATA_HOME: join(tmpdir(), 'overflow', 'data') + }) + ).toThrow('context limit') + service.restoreOriginalEnvironment(first) + expect(hash(first.OPENCODE_AUTH_CONTENT) === hash('same-baseline')).toBe(true) + }) +}) + +describe('original marker compatibility', () => { + it.each(['{', '[]', '{}', '{"inlineAuthReference":"invalid"}'])( + 'clears only proven overrides for malformed %s', + (marker) => { + const env = managedContext( + new ManagedDataAccountService('private-original-root'), + 'private-baseline' + ) + env.ORCA_DATA_ACCOUNT_ORIGINAL_ENV = marker + env.XDG_STATE_HOME = join(tmpdir(), 'independent-state') + restoreManagedDataAccountEnvironment(env) + expect(env.XDG_DATA_HOME).toBeUndefined() + expect(env.XDG_STATE_HOME).toBe(join(tmpdir(), 'independent-state')) + expect(env.OPENCODE_DB).toBe('opencode.db') + expect(env.ORCA_DATA_ACCOUNT_ORIGINAL_ENV).toBeUndefined() + } + ) + + it('restores a recognized old plaintext snapshot while stripping it on a foreign relay', () => { + const env = managedContext( + new ManagedDataAccountService('private-original-root'), + 'private-baseline' + ) + env.ORCA_DATA_ACCOUNT_ORIGINAL_ENV = JSON.stringify({ + XDG_DATA_HOME: join(tmpdir(), 'legacy-system-data'), + XDG_STATE_HOME: null, + OPENCODE_AUTH_CONTENT: 'legacy-inline', + OPENCODE_DB: 'legacy.db' + }) + const relay = { ...env } + restoreManagedDataAccountEnvironment(env) + restoreManagedDataAccountEnvironment(relay, false) + expect(hash(env.OPENCODE_AUTH_CONTENT) === hash('legacy-inline')).toBe(true) + expect(env.OPENCODE_DB).toBe('legacy.db') + expect(relay.XDG_DATA_HOME).toBeUndefined() + expect(relay.OPENCODE_AUTH_CONTENT).toBeUndefined() + expect(relay.ORCA_DATA_ACCOUNT_ORIGINAL_ENV).toBeUndefined() + }) + + it('preserves unknown old-client metadata and explicit System values without owned markers', () => { + const service = new ManagedDataAccountService('private-original-root') + const env = managedContext(service, 'private-baseline') + env.ORCA_DATA_ACCOUNT_PROVIDER = 'future-provider' + env.ORCA_DATA_ACCOUNT_FUTURE_METADATA = 'opaque-metadata' + const before = hash(JSON.stringify(env)) + service.restoreOriginalEnvironment(env) + expect(hash(JSON.stringify(env)) === before).toBe(true) + delete env.ORCA_DATA_ACCOUNT_DATA_HOME + env.OPENCODE_AUTH_CONTENT = 'explicit-system-inline' + service.restoreOriginalEnvironment(env) + expect(hash(env.OPENCODE_AUTH_CONTENT) === hash('explicit-system-inline')).toBe(true) + expect(env.ORCA_DATA_ACCOUNT_FUTURE_METADATA).toBe('opaque-metadata') + }) +}) diff --git a/src/main/managed-data-accounts/profile-removal.ts b/src/main/managed-data-accounts/profile-removal.ts new file mode 100644 index 00000000000..25f678317e8 --- /dev/null +++ b/src/main/managed-data-accounts/profile-removal.ts @@ -0,0 +1,138 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs' +import { readdir } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { z } from 'zod' +import { removeHostTree } from '../host-tree-removal' +import type { + ManagedDataAccountProvider, + ManagedDataAccountsState +} from '../../shared/managed-account-types' +import { writeSecureFile } from '../../shared/secure-file' + +export class ManagedDataAccountProfileRemoval { + constructor( + private readonly root: string, + private readonly assertOwned: (path: string) => void, + private readonly removeDirectory: (directory: string) => void | Promise = removeHostTree + ) {} + + async remove( + provider: ManagedDataAccountProvider, + accountId: string, + state: ManagedDataAccountsState, + publish: (state: ManagedDataAccountsState) => ManagedDataAccountsState, + changed: () => void + ): Promise { + if (!z.uuid().safeParse(accountId).success) { + throw new Error('Managed account not found.') + } + const directory = join(this.root, provider, accountId) + const pendingDirectory = join(this.root, provider, '.pending-delete', accountId) + const metadataPath = join(this.root, provider, 'accounts.json') + const rollbackPath = `${metadataPath}.${accountId}.rollback` + if (!state.accounts.some((account) => account.id === accountId)) { + if (existsSync(rollbackPath) && existsSync(directory)) { + this.assertOwned(rollbackPath) + this.quarantine(directory, pendingDirectory) + changed() + } else if (!existsSync(pendingDirectory)) { + throw new Error('Managed account not found.') + } + this.discardBackup(rollbackPath) + await this.cleanup(pendingDirectory) + return state + } + if (existsSync(rollbackPath)) { + this.assertOwned(rollbackPath) + } + if (!writeSecureFile(rollbackPath, readFileSync(metadataPath, 'utf8'), { durable: true })) { + rmSync(rollbackPath, { force: true }) + throw new Error('Could not restrict account metadata backup permissions.') + } + let next: ManagedDataAccountsState + try { + next = publish({ + accounts: state.accounts.filter((account) => account.id !== accountId), + activeAccountId: state.activeAccountId === accountId ? null : state.activeAccountId + }) + this.quarantine(directory, pendingDirectory) + } catch (error) { + try { + renameSync(rollbackPath, metadataPath) + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + 'Account removal failed and its private metadata backup could not be restored; retry removal to recover.', + { cause: error } + ) + } + throw error + } + this.discardBackup(rollbackPath) + changed() + // Cleanup can partially delete a tree, so it must never roll back a committed removal. + await this.cleanup(pendingDirectory) + return next + } + + private quarantine(directory: string, pendingDirectory: string): void { + if (!existsSync(directory)) { + return + } + this.assertOwned(directory) + if (existsSync(pendingDirectory)) { + throw new Error('Account already has a pending removal directory.') + } + const pendingRoot = dirname(pendingDirectory) + mkdirSync(pendingRoot, { recursive: true, mode: 0o700 }) + this.assertOwned(pendingRoot) + renameSync(directory, pendingDirectory) + } + + private discardBackup(rollbackPath: string): void { + try { + rmSync(rollbackPath, { force: true }) + } catch { + console.warn('[managed-data-accounts] Could not remove account metadata backup.') + } + } + + private async cleanup(directory: string): Promise { + if (!existsSync(directory)) { + return + } + try { + this.assertOwned(dirname(directory)) + this.assertOwned(directory) + await this.removeDirectory(directory) + } catch { + console.warn( + '[managed-data-accounts] Account removed; private directory cleanup is deferred.' + ) + } + } + + async retry(provider: ManagedDataAccountProvider, registered: Set): Promise { + const pendingRoot = join(this.root, provider, '.pending-delete') + if (!existsSync(pendingRoot)) { + return + } + try { + this.assertOwned(pendingRoot) + const entries = await readdir(pendingRoot, { withFileTypes: true }) + for (const entry of entries) { + if ( + !entry.isDirectory() || + !z.uuid().safeParse(entry.name).success || + registered.has(entry.name) + ) { + continue + } + await this.cleanup(join(pendingRoot, entry.name)) + this.discardBackup(join(this.root, provider, `accounts.json.${entry.name}.rollback`)) + } + } catch { + console.warn('[managed-data-accounts] Could not retry private account directory cleanup.') + } + } +} diff --git a/src/main/managed-data-accounts/service.test.ts b/src/main/managed-data-accounts/service.test.ts new file mode 100644 index 00000000000..5ab7f12d04c --- /dev/null +++ b/src/main/managed-data-accounts/service.test.ts @@ -0,0 +1,513 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { randomUUID } from 'node:crypto' +import * as fileSystem from 'node:fs' +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 * 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 + +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(() => { + vi.restoreAllMocks() + 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.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 when the atomic rename is locked and permits retry', async () => { + let locked = true + const before = await service.add('devin', source, 'Work') + const environment = service.launchEnvironment('devin') + const directory = join(root, 'managed', 'devin', before.accounts[0].id) + const rename = fileSystem.renameSync + vi.spyOn(fileSystem, 'renameSync').mockImplementation((from, to) => { + if (locked && from === directory) { + throw new Error('file locked') + } + return rename(from, to) + }) + const metadataPath = join(root, 'managed', 'devin', 'accounts.json') + const metadata = readFileSync(metadataPath) + const changed = vi.fn() + service.onChanged(changed) + await expect(service.remove('devin', before.accounts[0].id)).rejects.toThrow('file locked') + expect(service.list('devin')).toEqual(before) + 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('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 + 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.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')) + 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.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']) + }) + + 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') + writeFileSync(target, readFileSync(original)) + rmSync(original) + symlinkSync(target, original) + await expect(service.add('devin', source, 'Work')).rejects.toThrow('regular file') + expect(readFileSync(target, 'utf8')).toContain('test-only-key') + }) + + it('keeps credential parse errors out of RPC messages', async () => { + writeFileSync( + join(source, 'devin', 'credentials.toml'), + 'windsurf_api_key = "secret-not-for-errors' + ) + await expect(service.add('devin', source, 'Work')).rejects.toThrow( + 'Unsupported Devin credential format.' + ) + }) +}) diff --git a/src/main/managed-data-accounts/service.ts b/src/main/managed-data-accounts/service.ts new file mode 100644 index 00000000000..180565d23d4 --- /dev/null +++ b/src/main/managed-data-accounts/service.ts @@ -0,0 +1,294 @@ +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' +import { ManagedDataAccountProfileRemoval } from './profile-removal' +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 + .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>() + private readonly inlineAuthBaselines = new Map() + private readonly profileRemoval: ManagedDataAccountProfileRemoval + + constructor( + private readonly root: string, + 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') + 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(() => + this.profileRemoval.remove( + provider, + accountId, + this.list(provider), + (next) => this.writeState(provider, next), + () => this.notifyChanged() + ) + ) + } + + launchEnvironment(provider: ManagedDataAccountProvider): Record { + const state = this.list(provider) + if (!state.activeAccountId) { + return {} + } + 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)) + } + + 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 + ): Record { + const directory = this.requireAccount(provider, accountId) + 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 = 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), { durable: true })) { + throw new Error('Could not restrict account metadata permissions.') + } + return checked + } + + private notifyChanged(): void { + for (const listener of this.listeners) { + listener() + } + } + + 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 +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/main/opencode-usage/opencode-database-discovery.ts b/src/main/opencode-usage/opencode-database-discovery.ts index 7f7fea72ecf..5f7ca25415d 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 } } @@ -32,31 +35,44 @@ export async function listOpenCodeDatabases( * "OpenCode not used" rather than "we could not look". */ onRefusal?: (path: string, error: WslTranscriptFsError) => void, /** Every other stat/readdir failure, including ENOENT; also read as an empty list. */ - onFsError?: (path: string, error: unknown) => void + onFsError?: (path: string, error: unknown) => void, + signal?: AbortSignal, + 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 [] } try { - return (await wslGatedStat(databaseOverride.path, 'scan')).isFile() + return (await wslGatedStat(databaseOverride.path, 'scan', signal)).isFile() ? [databaseOverride.path] : [] } catch (error) { + signal?.throwIfAborted() reportFailure(databaseOverride.path, error, onRefusal, onFsError) return [] } } + return listOpenCodeDatabasesInDirectory(dataDirectory, onRefusal, signal, onFsError) +} + +export async function listOpenCodeDatabasesInDirectory( + dataDirectory: string, + onRefusal?: (path: string, error: WslTranscriptFsError) => void, + signal?: AbortSignal, + onFsError?: (path: string, error: unknown) => void +): Promise { try { - const entries = await wslGatedReaddir(dataDirectory, 'scan') + const entries = await wslGatedReaddir(dataDirectory, 'scan', signal) return entries .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) .map((entry) => join(dataDirectory, entry.name)) .sort() } catch (error) { + signal?.throwIfAborted() reportFailure(dataDirectory, error, onRefusal, onFsError) return [] } diff --git a/src/main/orcad/electron-serve-browser-process.test.ts b/src/main/orcad/electron-serve-browser-process.test.ts index 4994003eef2..d3ea8c87e23 100644 --- a/src/main/orcad/electron-serve-browser-process.test.ts +++ b/src/main/orcad/electron-serve-browser-process.test.ts @@ -136,6 +136,14 @@ 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 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() @@ -153,6 +161,15 @@ describe('ElectronServeBrowserProcess start-up', () => { expect(spec.env).not.toHaveProperty(key) } expect(spec.env?.ORCA_HARNESS_UNRELATED).toBe('preserved') + 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(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 ae3508289c9..d55020551ef 100644 --- a/src/main/orcad/electron-serve-browser-process.ts +++ b/src/main/orcad/electron-serve-browser-process.ts @@ -52,9 +52,12 @@ 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', + 'ORCA_USER_DATA', + 'ORCA_USER_DATA_PATH', 'AGENT_BROWSER_ARGS', 'AGENT_BROWSER_AUTO_CONNECT', 'AGENT_BROWSER_CDP', @@ -69,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 } @@ -110,9 +117,12 @@ 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() + env: electronServeEnvironment(userDataPath) }) this.child = child for (const stream of [child.stdout, child.stderr]) { diff --git a/src/main/powershell-osc133-bootstrap.ts b/src/main/powershell-osc133-bootstrap.ts index 0dd39fa2a67..57d9c2959a0 100644 --- a/src/main/powershell-osc133-bootstrap.ts +++ b/src/main/powershell-osc133-bootstrap.ts @@ -1,4 +1,5 @@ import { getPowerShellOmpShellWrapper } from './pty/omp-shell-wrapper' +import { MANAGED_DATA_ACCOUNT_POWERSHELL_RESTORE } from '../shared/managed-data-account-shell' import { getPowerShellCodexShellLaunchPreflight } from '../shared/codex-shell-function' export { encodePowerShellCommand } from '../shared/powershell-command-encoding' @@ -39,6 +40,7 @@ const POWERSHELL_OSC133_BOOTSTRAP = `# Orca OSC 133 shell integration for PowerS # Profiles have already loaded normally by the time -EncodedCommand runs. # Restore managed ownership before the shell-integration compatibility guard. if ($env:ORCA_OPENCODE_CONFIG_DIR) { $env:OPENCODE_CONFIG_DIR = $env:ORCA_OPENCODE_CONFIG_DIR } +${MANAGED_DATA_ACCOUNT_POWERSHELL_RESTORE} if ($env:ORCA_MIMOCODE_HOME) { $env:MIMOCODE_HOME = $env:ORCA_MIMOCODE_HOME } if ($env:ORCA_CODEX_HOME) { $env:CODEX_HOME = $env:ORCA_CODEX_HOME } diff --git a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts index 0bbcd0712ea..d16715ed496 100644 --- a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts +++ b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts @@ -5,6 +5,7 @@ * startup-file chain, OSC 133 hooks, and the shell-ready marker all live here. */ import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' +import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell' import { WSL_MANAGED_CLI_PATH_RESTORE } from '../wsl-managed-cli-path-restore' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function' @@ -50,6 +51,7 @@ ${WSL_MANAGED_CLI_PATH_RESTORE} # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +${MANAGED_DATA_ACCOUNT_POSIX_RESTORE} [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. diff --git a/src/main/providers/local-pty-spawn-environment.ts b/src/main/providers/local-pty-spawn-environment.ts index 645fcacb2d0..2d4694ba5ea 100644 --- a/src/main/providers/local-pty-spawn-environment.ts +++ b/src/main/providers/local-pty-spawn-environment.ts @@ -1,4 +1,5 @@ import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env' +import { restoreManagedDataAccountEnvironment } from '../../shared/managed-data-account-environment' import { ORCA_IMAGE_PROTOCOL_ENV, ORCA_IMAGE_PROTOCOL_VALUE @@ -22,8 +23,10 @@ export function buildLocalPtySpawnEnvironment(args: { plan: LocalPtyLaunchPlan }): Record | Promise> { const { id, spawn, getOptions, plan } = args + const inheritedEnv = stripInheritedBuildModeEnv(process.env) + restoreManagedDataAccountEnvironment(inheritedEnv) const spawnEnv: Record = { - ...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), spawn.env), + ...mergeGitConfigEnvProtocol(inheritedEnv, spawn.env), TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'Orca', diff --git a/src/main/runtime/rpc/methods/accounts.test.ts b/src/main/runtime/rpc/methods/accounts.test.ts index ac8948422ac..b8473d00e01 100644 --- a/src/main/runtime/rpc/methods/accounts.test.ts +++ b/src/main/runtime/rpc/methods/accounts.test.ts @@ -51,7 +51,11 @@ describe('account RPC methods', () => { it.each([ ['accounts.addClaudeFromConfigDir', { configDir: join(tmpdir(), 'claude-login') }], - ['accounts.addCodexFromHome', { sourceHome: join(tmpdir(), 'codex-login') }] + ['accounts.addCodexFromHome', { sourceHome: join(tmpdir(), 'codex-login') }], + [ + 'accounts.addDataFromHome', + { provider: 'opencode', sourceDataHome: join(tmpdir(), 'login'), label: 'Work' } + ] ])('rejects paired-device calls to %s', async (methodName, params) => { const runtime = { addClaudeAccountFromConfigDir: vi.fn(), diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts index f7fe0af90ec..9186e378da2 100644 --- a/src/main/runtime/rpc/methods/accounts.ts +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -1,5 +1,8 @@ import { defineMethod, defineStreamingMethod } from '../core' import { + AddDataAccountParams, + SelectDataAccountParams, + RemoveDataAccountParams, AccountsUnsubscribeParams, AddClaudeFromConfigDirParams, AddCodexFromHomeParams, @@ -25,6 +28,33 @@ let accountsSubscriptionSeq = 0 // `orca account add` CLI can register accounts on a headless host; it is gated // to the local runtime connection, never a mobile device token. See #1438. export const ACCOUNT_METHODS = [ + defineMethod({ + name: 'accounts.listData', + params: null, + handler: async (_, { runtime }) => runtime.getDataAccountsSnapshot() + }), + defineMethod({ + name: 'accounts.addDataFromHome', + params: AddDataAccountParams, + handler: async (params, { runtime, clientKind }) => { + if (clientKind !== undefined) { + throw new Error('Adding accounts is only available on the Orca host runtime.') + } + return runtime.addDataAccountFromHome(params.provider, params.sourceDataHome, params.label) + } + }), + defineMethod({ + name: 'accounts.selectData', + params: SelectDataAccountParams, + handler: async (params, { runtime }) => + runtime.selectDataAccount(params.provider, params.accountId) + }), + defineMethod({ + name: 'accounts.removeData', + params: RemoveDataAccountParams, + handler: async (params, { runtime }) => + runtime.removeDataAccount(params.provider, params.accountId) + }), defineMethod({ name: 'accounts.list', params: ListAccountsParams, diff --git a/src/main/runtime/runtime-account-controller.ts b/src/main/runtime/runtime-account-controller.ts index 45d3ade97ff..c824301e5a5 100644 --- a/src/main/runtime/runtime-account-controller.ts +++ b/src/main/runtime/runtime-account-controller.ts @@ -1,14 +1,18 @@ 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' import type { CodexAccountService, CodexResetCreditRejectedBeforeProviderReason } 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' @@ -21,6 +25,8 @@ export type RuntimeAccountServices = { } export type AccountsSnapshot = { + opencode?: ManagedDataAccountsState + devin?: ManagedDataAccountsState claude: ClaudeRateLimitAccountsState codex: CodexRateLimitAccountsState rateLimits: RateLimitState @@ -61,12 +67,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 +188,21 @@ export class RuntimeAccountController { onChanged(listener: (snapshot: AccountsSnapshot) => void): () => void { const services = this.requireServices() - return services.rateLimits.onStateChange((rateLimits) => { + const unsubscribeData = hasAppEnvironment() + ? getManagedDataAccountService().onChanged(() => listener(this.getSnapshot())) + : () => {} + const unsubscribeUsage = services.rateLimits.onStateChange((rateLimits) => { listener({ + ...this.dataAccountsSnapshot(), claude: services.claudeAccounts.listAccounts(), codex: services.codexAccounts.listAccounts(), rateLimits }) }) + return () => { + unsubscribeData() + unsubscribeUsage() + } } private requireServices(): RuntimeAccountServices { diff --git a/src/main/runtime/runtime-service-command-surface.ts b/src/main/runtime/runtime-service-command-surface.ts index 82b3204da52..fb993e625cd 100644 --- a/src/main/runtime/runtime-service-command-surface.ts +++ b/src/main/runtime/runtime-service-command-surface.ts @@ -41,6 +41,7 @@ export type RuntimeServiceCommandSurface = { registerMobilePushDevice: RuntimeMobileNotificationController['registerPushDevice'] unregisterMobilePushDevice: RuntimeMobileNotificationController['unregisterPushDevice'] setAccountServices: RuntimeAccountController['setServices'] + getDataAccountsSnapshot: RuntimeAccountController['dataAccountsSnapshot'] setCommitMessageAgentEnvironmentResolvers: RuntimeAccountController['setCommitMessageAgentEnvironment'] getCommitMessageAgentEnvironmentResolvers: RuntimeAccountController['getCommitMessageAgentEnvironment'] getAccountsSnapshot: RuntimeAccountController['getSnapshot'] @@ -54,6 +55,9 @@ export type RuntimeServiceCommandSurface = { addClaudeAccountFromConfigDir: RuntimeAccountController['addClaudeFromConfigDir'] removeCodexAccount: RuntimeAccountController['removeCodex'] addCodexAccountFromHome: RuntimeAccountController['addCodexFromHome'] + addDataAccountFromHome: RuntimeAccountController['addDataFromHome'] + selectDataAccount: RuntimeAccountController['selectData'] + removeDataAccount: RuntimeAccountController['removeData'] onAccountsChanged: RuntimeAccountController['onChanged'] listMobileSpeechModels: RuntimeMobileSpeechCatalog['list'] downloadMobileSpeechModel: RuntimeMobileSpeechCatalog['download'] @@ -132,6 +136,7 @@ export function installRuntimeServiceCommandSurface( registerMobilePushDevice: notifications.registerPushDevice.bind(notifications), unregisterMobilePushDevice: notifications.unregisterPushDevice.bind(notifications), setAccountServices: accounts.setServices.bind(accounts), + getDataAccountsSnapshot: accounts.dataAccountsSnapshot.bind(accounts), setCommitMessageAgentEnvironmentResolvers: accounts.setCommitMessageAgentEnvironment.bind(accounts), getCommitMessageAgentEnvironmentResolvers: @@ -147,6 +152,9 @@ export function installRuntimeServiceCommandSurface( addClaudeAccountFromConfigDir: accounts.addClaudeFromConfigDir.bind(accounts), removeCodexAccount: accounts.removeCodex.bind(accounts), addCodexAccountFromHome: accounts.addCodexFromHome.bind(accounts), + addDataAccountFromHome: accounts.addDataFromHome.bind(accounts), + selectDataAccount: accounts.selectData.bind(accounts), + removeDataAccount: accounts.removeData.bind(accounts), onAccountsChanged: accounts.onChanged.bind(accounts), listMobileSpeechModels: speech.list.bind(speech), downloadMobileSpeechModel: speech.download.bind(speech), diff --git a/src/main/shell-startup-features.ts b/src/main/shell-startup-features.ts index 95518967299..1a883e9f769 100644 --- a/src/main/shell-startup-features.ts +++ b/src/main/shell-startup-features.ts @@ -26,6 +26,7 @@ export type ShellStartupFeature = (typeof SHELL_STARTUP_FEATURES)[number] /** Spawn-env keys that mean this pane carries an Orca overlay the wrapper must re-apply. */ const OVERLAY_ENV_KEYS = [ + 'ORCA_DATA_ACCOUNT_DATA_HOME', 'ORCA_OPENCODE_CONFIG_DIR', 'ORCA_MIMOCODE_HOME', 'ORCA_OMP_STATUS_EXTENSION', diff --git a/src/main/shell-wrapper-generated-file-snapshot.test.ts b/src/main/shell-wrapper-generated-file-snapshot.test.ts index 36cd837e4fd..9e86ebdd15c 100644 --- a/src/main/shell-wrapper-generated-file-snapshot.test.ts +++ b/src/main/shell-wrapper-generated-file-snapshot.test.ts @@ -71,6 +71,10 @@ const CONTRACT_GLOBALS = new Set([ 'HISTFILE', 'MIMOCODE_HOME', 'OPENCODE_CONFIG_DIR', + 'OPENCODE_AUTH_CONTENT', + 'OPENCODE_DB', + 'XDG_DATA_HOME', + 'XDG_STATE_HOME', 'PATH', 'PROMPT_COMMAND', 'PS1', // Bash appends its non-printing Readline readiness marker. diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index 4fb4f8d01cf..d6d6ef39a30 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -1,3 +1,4 @@ +import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../shared/managed-data-account-shell' /** * The single `.zshenv` Orca writes for every transport: local PTY, daemon/SSH, * and relay. @@ -122,6 +123,7 @@ function getOverlayRestoreBlocks(spec: ZshStartupHookSpec): (string | null)[] { spec.overlayRestoreComment, spec.restores.agentTeamsPath ? AGENT_TEAMS_PATH_RESTORE_BLOCK : null, OPENCODE_CONFIG_DIR_RESTORE, + MANAGED_DATA_ACCOUNT_POSIX_RESTORE, MIMOCODE_HOME_RESTORE, spec.restores.remoteCliBinDir ? REMOTE_CLI_BIN_DIR_RESTORE : null, getPosixOmpShellWrapper(), diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index b80b7ffdbd1..61d62bc3f09 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -1,6 +1,7 @@ import type { TmuxManagedPty } from '../shared/tmux-agent-hook-owner' /* 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' @@ -846,9 +847,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', @@ -856,8 +861,13 @@ 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( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ) const augmented: Record = {} for (const augmenter of this.envAugmenters) { try { @@ -868,7 +878,11 @@ export class PtyHandler { ) } } - const result = mergeGitConfigEnvProtocol(baseEnv, augmented) as Record + const result: Record = Object.fromEntries( + Object.entries(mergeGitConfigEnvProtocol(baseEnv, augmented)).filter( + (entry): entry is [string, string] => typeof entry[1] === '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/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index e99453afabb..5bbf1b69d98 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -66,6 +66,7 @@ import { renderOpenCodeAccountsSection } from './accounts-pane-provider-setting-sections' import { renderMiniMaxAccountsSection } from './accounts-pane-minimax-section' +import { ManagedDataAccountsSection } from './ManagedDataAccountsSection' import { renderAccountsRemovalDialogs } from './accounts-pane-removal-dialogs' export { getAccountsPaneSearchEntries } @@ -375,6 +376,12 @@ export function AccountsPane({ clearMiniMaxCookie } const visibleSections = [ + !searchQuery || /opencode|devin|account/i.test(searchQuery) ? ( +
+ + +
+ ) : null, wslSupportedPlatform && !isRemoteAccountScope && matchesSettingsSearch(searchQuery, getAccountsLocationSearchEntries()) diff --git a/src/renderer/src/components/settings/ManagedDataAccountsSection.tsx b/src/renderer/src/components/settings/ManagedDataAccountsSection.tsx new file mode 100644 index 00000000000..181a572a594 --- /dev/null +++ b/src/renderer/src/components/settings/ManagedDataAccountsSection.tsx @@ -0,0 +1,236 @@ +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' + ? translate('auto.lib.agent.catalog.e7a4ca5103', 'OpenCode') + : translate('auto.lib.agent.catalog.fc80296033', '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 88943327a37..69322548a90 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18840,6 +18840,20 @@ "google": "google", "accounts": "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 abb19a1afff..9584931fdde 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15550,5 +15550,21 @@ "searchFailed": "La búsqueda no pudo finalizar. Inténtalo de nuevo.", "refreshFailed": "No se pudo actualizar la vista previa. Se muestra la versión anterior; abre la vista de código para ver el contenido actual." } + }, + "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 017ee021cf8..63215066d3c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18661,5 +18661,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 da7e1da6d95..6c273b57a43 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18661,5 +18661,21 @@ "addTypedPath": "Add", "noDirectories": "No folders added yet.", "removePath": "Remove {{name}}" + }, + "accounts": { + "managedData": { + "description": "Orca ホストのターミナルでこのコマンドを実行してアカウントを追加します。選択はそのホストでの新規 Agent 起動に適用されます。直接 SSH 接続とWindows上の WSL は独自の認証情報を使用します。", + "copied": "コピーしました", + "add": "アカウント追加コマンドをコピー", + "refresh": "アカウントを更新", + "upgrade": "アカウントが表示されない場合はOrcaホストを更新または再起動してください。", + "system": "システムの既定", + "active": "使用中", + "select": "選択", + "remove": "削除", + "removeTitle": "管理アカウントを削除しますか?", + "removeDescription": "まずこのプロファイルを使う Agent を停止してください。削除すると保存済みの認証情報と会話データが消去されます。システムのログインには影響しません。", + "cancel": "キャンセル" + } } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 78504ce8971..f77d10b175c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18661,5 +18661,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 b14ef912ea7..de83f7fcd77 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18713,5 +18713,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/shared/managed-account-types.ts b/src/shared/managed-account-types.ts index 7239c62e6ad..a4f7c272ac6 100644 --- a/src/shared/managed-account-types.ts +++ b/src/shared/managed-account-types.ts @@ -1,3 +1,17 @@ +export type ManagedDataAccountProvider = 'opencode' | 'devin' + +export type ManagedDataAccountSummary = { + id: string + label: string + integrations: string[] + createdAt: number +} + +export type ManagedDataAccountsState = { + accounts: ManagedDataAccountSummary[] + activeAccountId: string | null +} + export type CodexManagedAccount = { id: string email: string diff --git a/src/shared/managed-data-account-environment.ts b/src/shared/managed-data-account-environment.ts new file mode 100644 index 00000000000..313324533a1 --- /dev/null +++ b/src/shared/managed-data-account-environment.ts @@ -0,0 +1,83 @@ +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(), + inlineAuthReference: z.uuid().optional() +}) +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 captureManagedDataAccountOriginalEnvironment( + environment: Record, + inlineAuthReference?: string +): void { + 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, + 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')) + if (restoreOriginal && parsed.success) { + original = parsed.data + } + } 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 + ): void { + if (ownedValue === undefined || environment[key] !== ownedValue) { + return + } + const value = key === 'OPENCODE_AUTH_CONTENT' ? inlineAuth : original?.[key] + if (typeof value === 'string') { + environment[key] = value + } else { + delete environment[key] + } + } + 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 + delete environment.ORCA_DATA_ACCOUNT_PROVIDER + delete environment[ORIGINAL_ENV] +} diff --git a/src/shared/managed-data-account-shell.ts b/src/shared/managed-data-account-shell.ts new file mode 100644 index 00000000000..357248220ca --- /dev/null +++ b/src/shared/managed-data-account-shell.ts @@ -0,0 +1,26 @@ +export const MANAGED_DATA_ACCOUNT_POSIX_RESTORE = `if [[ -n "\${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then + export XDG_DATA_HOME="\${ORCA_DATA_ACCOUNT_DATA_HOME}" + export XDG_STATE_HOME="\${ORCA_DATA_ACCOUNT_STATE_HOME}" + if [[ "\${ORCA_DATA_ACCOUNT_PROVIDER:-}" == opencode ]]; then + export OPENCODE_AUTH_CONTENT="" + export OPENCODE_DB="opencode.db" + fi +fi` + +export const MANAGED_DATA_ACCOUNT_POWERSHELL_RESTORE = `if ($env:ORCA_DATA_ACCOUNT_DATA_HOME) { + $env:XDG_DATA_HOME = $env:ORCA_DATA_ACCOUNT_DATA_HOME + $env:XDG_STATE_HOME = $env:ORCA_DATA_ACCOUNT_STATE_HOME + if ($env:ORCA_DATA_ACCOUNT_PROVIDER -eq 'opencode') { + $env:OPENCODE_AUTH_CONTENT = '' + $env:OPENCODE_DB = 'opencode.db' + } +}` + +export const MANAGED_DATA_ACCOUNT_FISH_RESTORE = ` if set -q ORCA_DATA_ACCOUNT_DATA_HOME; and test -n "$ORCA_DATA_ACCOUNT_DATA_HOME" + set -gx XDG_DATA_HOME "$ORCA_DATA_ACCOUNT_DATA_HOME" + set -gx XDG_STATE_HOME "$ORCA_DATA_ACCOUNT_STATE_HOME" + if test "$ORCA_DATA_ACCOUNT_PROVIDER" = opencode + set -gx OPENCODE_AUTH_CONTENT '' + set -gx OPENCODE_DB opencode.db + end + end` diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 650bc85b449..7fc53bbf900 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -131,6 +131,7 @@ export const WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY = 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 ANTIGRAVITY_ACCOUNTS_RUNTIME_CAPABILITY = 'accounts.antigravity-native.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 @@ -429,6 +430,7 @@ export const RUNTIME_CAPABILITIES = [ WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, ACCOUNT_IMPORT_RUNTIME_CAPABILITY, ANTIGRAVITY_ACCOUNTS_RUNTIME_CAPABILITY, + DATA_ACCOUNT_RUNTIME_CAPABILITY, CODEX_RESET_CREDIT_RUNTIME_CAPABILITY, SKILL_INSTALL_CAPABILITY, SKILL_BUNDLE_INSTALL_CAPABILITY, diff --git a/src/shared/rpc-contract/accounts-params.ts b/src/shared/rpc-contract/accounts-params.ts index 0a9559e9549..7fa31ee6b80 100644 --- a/src/shared/rpc-contract/accounts-params.ts +++ b/src/shared/rpc-contract/accounts-params.ts @@ -1,5 +1,19 @@ import { z } from 'zod' +export const ManagedDataAccountProviderParams = z.object({ + provider: z.enum(['opencode', 'devin']) +}) +export const AddDataAccountParams = ManagedDataAccountProviderParams.extend({ + sourceDataHome: z.string().min(1), + label: z.string().trim().min(1).max(120) +}) +export const SelectDataAccountParams = ManagedDataAccountProviderParams.extend({ + accountId: z.uuid().nullable() +}) +export const RemoveDataAccountParams = ManagedDataAccountProviderParams.extend({ + accountId: z.uuid() +}) + export const CodexResetTarget = z.discriminatedUnion('runtime', [ z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(), // Why: reset scope must identify one exact WSL distro; null means all slots only for selection. diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 0a4f0b64969..d6d81433e8e 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' @@ -562,17 +565,21 @@ import { export const RPC_PARAMS_BY_METHOD = { 'accounts.addClaudeFromConfigDir': AddClaudeFromConfigDirParams, 'accounts.addCodexFromHome': AddCodexFromHomeParams, + 'accounts.addDataFromHome': AddDataAccountParams, 'accounts.antigravityAddCurrent': AntigravityAccountTargetParams, 'accounts.antigravityList': AntigravityAccountTargetParams, 'accounts.antigravityRemove': AntigravityAccountMutationParams, 'accounts.antigravitySelect': AntigravityAccountMutationParams, '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,