- {provider === 'opencode' ? 'OpenCode' : 'Devin'}
+
+ {provider === 'opencode'
+ ? translate('auto.lib.agent.catalog.e7a4ca5103', 'OpenCode')
+ : translate('auto.lib.agent.catalog.fc80296033', 'Devin')}
+
{translate(
'accounts.managedData.description',
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 8fe5e4b07ab4..38db9a3923bf 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -18660,7 +18660,7 @@
},
"accounts": {
"managedData": {
- "description": "Orcaホストのターミナルでこのコマンドを実行してアカウントを追加します。選択はそのホストでの新しいエージェント起動に適用されます。直接SSH接続とWindows上のWSLは独自の認証情報を使用します。",
+ "description": "Orca ホストのターミナルでこのコマンドを実行してアカウントを追加します。選択はそのホストでの新規 Agent 起動に適用されます。直接 SSH 接続とWindows上の WSL は独自の認証情報を使用します。",
"copied": "コピーしました",
"add": "アカウント追加コマンドをコピー",
"refresh": "アカウントを更新",
@@ -18670,7 +18670,7 @@
"select": "選択",
"remove": "削除",
"removeTitle": "管理アカウントを削除しますか?",
- "removeDescription": "まずこのプロファイルを使うエージェントを停止してください。削除すると保存済みの認証情報と会話データが消去されます。システムのログインには影響しません。",
+ "removeDescription": "まずこのプロファイルを使う Agent を停止してください。削除すると保存済みの認証情報と会話データが消去されます。システムのログインには影響しません。",
"cancel": "キャンセル"
}
}
From 8a95b0cf3acaf34ad47d7efbb0ea28cc0570f713 Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 06:31:43 -0700
Subject: [PATCH 13/24] Keep managed account baselines private to the execution
host
---
.../opencode-hook-installation.test.ts | 2 +-
src/main/ipc/pty/ipc/spawn-options.ts | 2 -
...ged-data-account-spawn-environment.test.ts | 179 ++++++++++++++
src/main/ipc/pty/runtime/spawn-options.ts | 2 -
.../launch-environment.test.ts | 24 +-
.../launch-environment.ts | 15 +-
.../original-environment.test.ts | 231 ++++++++++++++++++
src/main/managed-data-accounts/service.ts | 74 ++++++
.../managed-data-account-environment.ts | 59 +++--
9 files changed, 537 insertions(+), 51 deletions(-)
create mode 100644 src/main/ipc/pty/managed-data-account-spawn-environment.test.ts
create mode 100644 src/main/managed-data-accounts/original-environment.test.ts
diff --git a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts
index 8a9114eb9427..a52a9d703604 100644
--- a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts
+++ b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts
@@ -12,7 +12,7 @@ import type { BuildPtyHostEnvOptions } from './types'
const fixture = vi.hoisted(() => ({ userData: '', guestOverlay: '' }))
vi.mock('../../../../shared/app-environment', () => ({
- getAppEnvironment: () => ({ getPath: () => fixture.userData })
+ getAppEnvironment: () => ({ getPath: () => fixture.userData, onWillQuit: vi.fn() })
}))
vi.mock('../../../agent-hooks/server', () => ({
agentHookServer: { buildPtyEnv: () => ({ ORCA_AGENT_HOOK_PORT: '12345' }) }
diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts
index 411cb14f2871..d0a3ba8cef16 100644
--- a/src/main/ipc/pty/ipc/spawn-options.ts
+++ b/src/main/ipc/pty/ipc/spawn-options.ts
@@ -1,5 +1,4 @@
import { getAppEnvironment } from '../../../../shared/app-environment'
-import { getInheritedManagedDataAccountEnvKeysToDelete } from '../../../../shared/managed-data-account-environment'
import { getLegacyOpenCodeEnvKeysToDelete } from '../../../opencode/legacy-shared-config-dir'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment'
@@ -51,7 +50,6 @@ export async function buildPtyIpcSpawnOptions(
// Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH.
ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [],
ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.spawnEnv) : [],
- ctx.isDaemonHostSpawn ? getInheritedManagedDataAccountEnvKeysToDelete(ctx.spawnEnv) : [],
// The daemon must judge its own inherited value; main may have a different config.
!args.connectionId && !ctx.isDaemonHostSpawn
? getLegacyOpenCodeEnvKeysToDelete(ctx.spawnEnv, getAppEnvironment().getPath('userData'))
diff --git a/src/main/ipc/pty/managed-data-account-spawn-environment.test.ts b/src/main/ipc/pty/managed-data-account-spawn-environment.test.ts
new file mode 100644
index 000000000000..ed13aa02de94
--- /dev/null
+++ b/src/main/ipc/pty/managed-data-account-spawn-environment.test.ts
@@ -0,0 +1,179 @@
+import { randomUUID, createHash } from 'node:crypto'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { createDaemonPtyEnvironment } from '../../daemon/pty-subprocess/spawn-environment'
+import { applyManagedDataAccountEnvironment } from '../../managed-data-accounts/launch-environment'
+import { buildPtyIpcSpawnOptions } from './ipc/spawn-options'
+import { createPtyIpcSpawnState } from './ipc/spawn-state'
+import type { PtySpawnIpcDeps } from './ipc/spawn-types'
+import { buildRuntimePtySpawnOptions } from './runtime/spawn-options'
+import { createRuntimePtySpawnState } from './runtime/spawn-state'
+import type { PtyRuntimeControllerDeps } from './runtime/controller-deps'
+import type * as ServiceModule from '../../managed-data-accounts/service'
+import {
+ MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS,
+ restoreManagedDataAccountEnvironment
+} from '../../../shared/managed-data-account-environment'
+
+const selected = vi.hoisted(() => {
+ const value: Record = {}
+ return { value }
+})
+vi.mock('../../managed-data-accounts/service', async (importOriginal) => {
+ const actual = await importOriginal()
+ const service = new actual.ManagedDataAccountService('test-managed-root')
+ vi.spyOn(service, 'launchEnvironment').mockImplementation(() => selected.value)
+ return { ...actual, getManagedDataAccountService: () => service }
+})
+
+beforeEach(() => {
+ for (const key of [
+ ...MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS,
+ 'ORCA_DATA_ACCOUNT_DATA_HOME',
+ 'ORCA_DATA_ACCOUNT_STATE_HOME',
+ 'ORCA_DATA_ACCOUNT_PROVIDER',
+ 'ORCA_DATA_ACCOUNT_ORIGINAL_ENV'
+ ]) {
+ vi.stubEnv(key, undefined)
+ }
+ selected.value = {}
+})
+afterEach(() => vi.unstubAllEnvs())
+
+function hash(value: string | undefined): string | undefined {
+ return value === undefined ? undefined : createHash('sha256').update(value).digest('hex')
+}
+
+async function spawnDeletions(
+ route: string,
+ env: Record,
+ envToDelete?: string[]
+): Promise {
+ const args = { cols: 80, rows: 24, envToDelete }
+ if (route === 'renderer') {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: option construction with no workspace reads no required dependency methods.
+ const ctx = createPtyIpcSpawnState({} as PtySpawnIpcDeps, args)
+ ctx.env = env
+ ctx.isDaemonHostSpawn = true
+ await buildPtyIpcSpawnOptions(ctx)
+ ctx.finishTerminalInstall()
+ return ctx.spawnOptions.envToDelete ?? []
+ }
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: option construction with no workspace reads no required dependency methods.
+ const ctx = createRuntimePtySpawnState({} as PtyRuntimeControllerDeps, args)
+ ctx.env = env
+ ctx.isDaemonHostSpawn = true
+ await buildRuntimePtySpawnOptions(ctx)
+ ctx.finishTerminalInstall()
+ return ctx.spawnOptions.envToDelete ?? []
+}
+
+describe.each(['renderer', 'runtime'])('%s managed account daemon environment', (route) => {
+ it('honors explicit user deletions and preserves unknown daemon metadata', async () => {
+ const env: Record = {}
+ const envToDelete = await spawnDeletions(route, env, ['XDG_STATE_HOME'])
+ const inherited = {
+ XDG_DATA_HOME: join(tmpdir(), 'daemon-data'),
+ XDG_STATE_HOME: join(tmpdir(), 'daemon-state'),
+ OPENCODE_DB: 'daemon.db',
+ ORCA_DATA_ACCOUNT_DATA_HOME: join(tmpdir(), 'future-profile'),
+ ORCA_DATA_ACCOUNT_PROVIDER: 'future-provider',
+ ORCA_DATA_ACCOUNT_ORIGINAL_ENV: '{"future":"opaque"}',
+ ORCA_DATA_ACCOUNT_FUTURE_METADATA: 'opaque-metadata'
+ }
+ for (const [key, value] of Object.entries(inherited)) {
+ vi.stubEnv(key, value)
+ }
+ const child = createDaemonPtyEnvironment({
+ sessionId: 'pane',
+ cols: 80,
+ rows: 24,
+ env,
+ envToDelete
+ })
+ expect(child.XDG_STATE_HOME).toBeUndefined()
+ for (const [key, value] of Object.entries(inherited)) {
+ if (key !== 'XDG_STATE_HOME') {
+ expect(child[key]).toBe(value)
+ }
+ }
+ })
+
+ it('preserves independent daemon defaults when main has no baseline or owned markers', async () => {
+ const env: Record = {}
+ applyManagedDataAccountEnvironment(env, { launchAgent: 'opencode' })
+ const envToDelete = await spawnDeletions(route, env)
+ const defaults = {
+ XDG_DATA_HOME: join(tmpdir(), 'persistent-daemon', 'data'),
+ XDG_STATE_HOME: join(tmpdir(), 'persistent-daemon', 'state'),
+ OPENCODE_DB: 'daemon.db',
+ OPENCODE_AUTH_CONTENT: JSON.stringify({ fixture: { key: randomUUID() } })
+ }
+ for (const [key, value] of Object.entries(defaults)) {
+ vi.stubEnv(key, value)
+ }
+ const child = createDaemonPtyEnvironment({
+ sessionId: 'pane',
+ cols: 80,
+ rows: 24,
+ env,
+ envToDelete
+ })
+ for (const [key, value] of Object.entries(defaults)) {
+ expect(hash(child[key]) === hash(value)).toBe(true)
+ }
+ })
+
+ it('keeps inline System authentication out of every selected child marker', async () => {
+ const secret = randomUUID()
+ const baseline = JSON.stringify({ fixture: { key: secret } })
+ vi.stubEnv('OPENCODE_AUTH_CONTENT', baseline)
+ selected.value = {
+ XDG_DATA_HOME: join(tmpdir(), 'selected-account', 'data'),
+ XDG_STATE_HOME: join(tmpdir(), 'selected-account', 'state'),
+ OPENCODE_AUTH_CONTENT: '',
+ OPENCODE_DB: 'opencode.db'
+ }
+ const env: Record = {}
+ applyManagedDataAccountEnvironment(env, { launchAgent: 'opencode' })
+ const envToDelete = await spawnDeletions(route, env)
+ const child = createDaemonPtyEnvironment({
+ sessionId: 'pane',
+ cols: 80,
+ rows: 24,
+ env,
+ envToDelete
+ })
+ expect(child.OPENCODE_AUTH_CONTENT).toBe('')
+ expect(Object.values(child).some((value) => value.includes(secret))).toBe(false)
+ selected.value = {}
+ vi.stubEnv('OPENCODE_AUTH_CONTENT', undefined)
+ const copied = { ...child }
+ applyManagedDataAccountEnvironment(copied, { launchAgent: 'opencode' })
+ expect(hash(copied.OPENCODE_AUTH_CONTENT) === hash(baseline)).toBe(true)
+ for (const key of MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS) {
+ vi.stubEnv(key, child[key])
+ }
+ for (const key of [
+ 'ORCA_DATA_ACCOUNT_DATA_HOME',
+ 'ORCA_DATA_ACCOUNT_STATE_HOME',
+ 'ORCA_DATA_ACCOUNT_PROVIDER',
+ 'ORCA_DATA_ACCOUNT_ORIGINAL_ENV'
+ ]) {
+ vi.stubEnv(key, child[key])
+ }
+ const systemDeletions = await spawnDeletions(route, copied)
+ const systemChild = createDaemonPtyEnvironment({
+ sessionId: 'pane',
+ cols: 80,
+ rows: 24,
+ env: copied,
+ envToDelete: systemDeletions
+ })
+ expect(hash(systemChild.OPENCODE_AUTH_CONTENT) === hash(baseline)).toBe(true)
+ expect(systemChild.ORCA_DATA_ACCOUNT_DATA_HOME).toBeUndefined()
+ restoreManagedDataAccountEnvironment(copied)
+ expect(hash(copied.OPENCODE_AUTH_CONTENT) === hash(baseline)).toBe(true)
+ })
+})
diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts
index 56697f94badb..5f64779b364d 100644
--- a/src/main/ipc/pty/runtime/spawn-options.ts
+++ b/src/main/ipc/pty/runtime/spawn-options.ts
@@ -1,5 +1,4 @@
import { getAppEnvironment } from '../../../../shared/app-environment'
-import { getInheritedManagedDataAccountEnvKeysToDelete } from '../../../../shared/managed-data-account-environment'
import { getLegacyOpenCodeEnvKeysToDelete } from '../../../opencode/legacy-shared-config-dir'
import type { IPtyProvider, PtySpawnResult } from '../../../providers/types'
import { LocalPtyProvider } from '../../../providers/local-pty-provider'
@@ -80,7 +79,6 @@ export async function buildRuntimePtySpawnOptions(
// Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH.
ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [],
ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.env) : [],
- ctx.isDaemonHostSpawn ? getInheritedManagedDataAccountEnvKeysToDelete(ctx.env) : [],
// The daemon must judge its own inherited value; main may have a different config.
!args.connectionId && !ctx.isDaemonHostSpawn
? getLegacyOpenCodeEnvKeysToDelete(ctx.env, getAppEnvironment().getPath('userData'))
diff --git a/src/main/managed-data-accounts/launch-environment.test.ts b/src/main/managed-data-accounts/launch-environment.test.ts
index 85e5a669626b..be6be25b175e 100644
--- a/src/main/managed-data-accounts/launch-environment.test.ts
+++ b/src/main/managed-data-accounts/launch-environment.test.ts
@@ -1,21 +1,22 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
-import {
- captureManagedDataAccountOriginalEnvironment,
- restoreManagedDataAccountEnvironment,
- getInheritedManagedDataAccountEnvKeysToDelete
-} from '../../shared/managed-data-account-environment'
+import { restoreManagedDataAccountEnvironment } from '../../shared/managed-data-account-environment'
import { createDaemonPtyEnvironment } from '../daemon/pty-subprocess/spawn-environment'
+import type * as ServiceModule from './service'
const selected = vi.hoisted(() => {
const value: Record = {}
return { value }
})
-vi.mock('./service', () => ({
- getManagedDataAccountService: () => ({ launchEnvironment: () => selected.value })
-}))
+vi.mock('./service', async (importOriginal) => {
+ const actual = await importOriginal()
+ const service = new actual.ManagedDataAccountService('test-managed-root')
+ vi.spyOn(service, 'launchEnvironment').mockImplementation(() => selected.value)
+ return { ...actual, getManagedDataAccountService: () => service }
+})
import { applyManagedDataAccountEnvironment } from './launch-environment'
+import { getManagedDataAccountService } from './service'
function inheritedProfile(): Record {
const env = {
@@ -23,7 +24,11 @@ function inheritedProfile(): Record {
OPENCODE_DB: 'user.db',
OPENCODE_AUTH_CONTENT: 'user-config'
}
- captureManagedDataAccountOriginalEnvironment(env)
+ getManagedDataAccountService().captureOriginalEnvironment(env, {
+ XDG_DATA_HOME: join(tmpdir(), 'old-account', 'data'),
+ XDG_STATE_HOME: join(tmpdir(), 'old-account', 'state'),
+ OPENCODE_AUTH_CONTENT: ''
+ })
return {
...env,
XDG_DATA_HOME: join(tmpdir(), 'old-account', 'data'),
@@ -61,7 +66,6 @@ describe('managed account inherited environment', () => {
expect(final.XDG_DATA_HOME).toBe(join(tmpdir(), 'user-data'))
expect(final.XDG_STATE_HOME).toBeUndefined()
expect(final.ORCA_DATA_ACCOUNT_DATA_HOME).toBeUndefined()
- expect(getInheritedManagedDataAccountEnvKeysToDelete(env)).toContain('XDG_STATE_HOME')
})
it('keeps a fresh selection after the daemon restores its own parent profile', () => {
diff --git a/src/main/managed-data-accounts/launch-environment.ts b/src/main/managed-data-accounts/launch-environment.ts
index 8546b001cf96..51a44e64e26a 100644
--- a/src/main/managed-data-accounts/launch-environment.ts
+++ b/src/main/managed-data-accounts/launch-environment.ts
@@ -4,19 +4,16 @@ import {
} from '../../shared/command-token-scanner'
import type { TuiAgent } from '../../shared/tui-agent'
import { getManagedDataAccountService } from './service'
-import {
- captureManagedDataAccountOriginalEnvironment,
- MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS,
- restoreManagedDataAccountEnvironment
-} from '../../shared/managed-data-account-environment'
+import { MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS } from '../../shared/managed-data-account-environment'
export function applyManagedDataAccountEnvironment(
environment: Record,
options: { launchAgent?: TuiAgent; launchCommand?: string; isWsl?: boolean }
): void {
- restoreManagedDataAccountEnvironment(environment)
+ const service = getManagedDataAccountService()
+ service.restoreOriginalEnvironment(environment)
const inherited = { ...process.env }
- restoreManagedDataAccountEnvironment(inherited)
+ service.restoreOriginalEnvironment(inherited)
for (const key of MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS) {
const value = inherited[key]
if (environment[key] === undefined && value !== undefined) {
@@ -37,11 +34,11 @@ export function applyManagedDataAccountEnvironment(
if (!provider) {
return
}
- const selected = getManagedDataAccountService().launchEnvironment(provider)
+ const selected = service.launchEnvironment(provider)
if (!selected.XDG_DATA_HOME) {
return
}
- captureManagedDataAccountOriginalEnvironment(environment)
+ service.captureOriginalEnvironment(environment, selected)
Object.assign(environment, selected)
environment.ORCA_DATA_ACCOUNT_DATA_HOME = selected.XDG_DATA_HOME
environment.ORCA_DATA_ACCOUNT_STATE_HOME = selected.XDG_STATE_HOME
diff --git a/src/main/managed-data-accounts/original-environment.test.ts b/src/main/managed-data-accounts/original-environment.test.ts
new file mode 100644
index 000000000000..dbe1ed1ad940
--- /dev/null
+++ b/src/main/managed-data-accounts/original-environment.test.ts
@@ -0,0 +1,231 @@
+import { createHash } from 'node:crypto'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { describe, expect, it } from 'vitest'
+import { ManagedDataAccountService, getManagedDataAccountService } from './service'
+import { getAppEnvironment, setAppEnvironment } from '../../shared/app-environment'
+import { restoreManagedDataAccountEnvironment } from '../../shared/managed-data-account-environment'
+
+const selected = {
+ XDG_DATA_HOME: join(tmpdir(), 'managed-original', 'data'),
+ XDG_STATE_HOME: join(tmpdir(), 'managed-original', 'state'),
+ OPENCODE_AUTH_CONTENT: '',
+ OPENCODE_DB: 'opencode.db'
+}
+
+function hash(value: string | undefined): string | undefined {
+ return value === undefined ? undefined : createHash('sha256').update(value).digest('hex')
+}
+
+function managedContext(
+ service: ManagedDataAccountService,
+ inline: string,
+ profile = selected
+): Record {
+ const env: Record = {
+ XDG_DATA_HOME: join(tmpdir(), 'system-original', 'data'),
+ OPENCODE_AUTH_CONTENT: inline,
+ OPENCODE_DB: 'system.db'
+ }
+ service.captureOriginalEnvironment(env, profile)
+ return {
+ ...env,
+ ...profile,
+ ORCA_DATA_ACCOUNT_DATA_HOME: profile.XDG_DATA_HOME,
+ ORCA_DATA_ACCOUNT_STATE_HOME: profile.XDG_STATE_HOME,
+ ORCA_DATA_ACCOUNT_PROVIDER: 'opencode'
+ }
+}
+
+describe('host-private managed account originals', () => {
+ it('restores multiple copied baselines without exposing either in a marker', () => {
+ const service = new ManagedDataAccountService('private-original-root')
+ const first = managedContext(service, 'first-inline-baseline')
+ const second = managedContext(service, 'second-inline-baseline')
+ for (const [env, inline] of [
+ [first, 'first-inline-baseline'],
+ [second, 'second-inline-baseline']
+ ] as const) {
+ expect(Object.values(env).some((value) => value.includes(inline))).toBe(false)
+ const copy = { ...env }
+ service.restoreOriginalEnvironment(copy)
+ expect(hash(copy.OPENCODE_AUTH_CONTENT) === hash(inline)).toBe(true)
+ expect(copy.XDG_DATA_HOME).toBe(join(tmpdir(), 'system-original', 'data'))
+ expect(copy.XDG_STATE_HOME).toBeUndefined()
+ expect(copy.OPENCODE_DB).toBe('system.db')
+ expect(copy.ORCA_DATA_ACCOUNT_ORIGINAL_ENV).toBeUndefined()
+ }
+ })
+
+ it.each([
+ { XDG_DATA_HOME: join(tmpdir(), 'independent-data') },
+ { XDG_STATE_HOME: join(tmpdir(), 'independent-state') },
+ { ORCA_DATA_ACCOUNT_DATA_HOME: join(tmpdir(), 'foreign-data') },
+ { ORCA_DATA_ACCOUNT_STATE_HOME: join(tmpdir(), 'foreign-state') },
+ { ORCA_DATA_ACCOUNT_PROVIDER: 'devin' },
+ { OPENCODE_DB: 'independent.db' },
+ { OPENCODE_AUTH_CONTENT: 'independent-inline' }
+ ])('requires the recorded selection, beyond a caller-supplied reference: %o', (override) => {
+ const service = new ManagedDataAccountService('private-original-root')
+ const env = { ...managedContext(service, 'private-baseline'), ...override }
+ service.restoreOriginalEnvironment(env)
+ expect(Object.values(env).some((value) => value.includes('private-baseline'))).toBe(false)
+ for (const [key, value] of Object.entries(override)) {
+ if (!key.startsWith('ORCA_DATA_ACCOUNT_')) {
+ expect(env[key]).toBe(value)
+ }
+ }
+ })
+
+ it('does not resolve a reference attached to a different otherwise valid profile', () => {
+ const service = new ManagedDataAccountService('private-original-root')
+ const captured = managedContext(service, 'private-baseline')
+ const foreign = managedContext(service, 'other-baseline', {
+ ...selected,
+ XDG_DATA_HOME: join(tmpdir(), 'different-profile', 'data'),
+ XDG_STATE_HOME: join(tmpdir(), 'different-profile', 'state')
+ })
+ foreign.ORCA_DATA_ACCOUNT_ORIGINAL_ENV = captured.ORCA_DATA_ACCOUNT_ORIGINAL_ENV
+ service.restoreOriginalEnvironment(foreign)
+ expect(foreign.OPENCODE_AUTH_CONTENT).toBeUndefined()
+ })
+
+ it('does not resolve private references after service restart or explicit disposal', () => {
+ const service = new ManagedDataAccountService('private-original-root')
+ const captured = managedContext(service, 'private-baseline')
+ const restarted = new ManagedDataAccountService('private-original-root')
+ const foreign = { ...captured }
+ restarted.restoreOriginalEnvironment(foreign)
+ expect(foreign.OPENCODE_AUTH_CONTENT).toBeUndefined()
+ service.clearInlineAuthBaselines()
+ service.restoreOriginalEnvironment(captured)
+ expect(captured.OPENCODE_AUTH_CONTENT).toBeUndefined()
+ })
+
+ it('disposes sensitive originals when the host userData root changes or shuts down', () => {
+ const original = getAppEnvironment()
+ let root = join(tmpdir(), 'original-host-one')
+ let shutdown: (() => void) | undefined
+ try {
+ setAppEnvironment({
+ ...original,
+ onWillQuit: (handler) => {
+ shutdown = handler
+ },
+ getPath: (name) => (name === 'userData' ? root : original.getPath(name))
+ })
+ const first = getManagedDataAccountService()
+ const captured = managedContext(first, 'private-baseline')
+ root = join(tmpdir(), 'original-host-two')
+ const second = getManagedDataAccountService()
+ const copy = { ...captured }
+ second.restoreOriginalEnvironment(copy)
+ first.restoreOriginalEnvironment(captured)
+ expect(copy.OPENCODE_AUTH_CONTENT).toBeUndefined()
+ expect(captured.OPENCODE_AUTH_CONTENT).toBeUndefined()
+ const current = managedContext(second, 'current-baseline')
+ expect(shutdown).toBeDefined()
+ shutdown?.()
+ second.restoreOriginalEnvironment(current)
+ expect(current.OPENCODE_AUTH_CONTENT).toBeUndefined()
+ } finally {
+ setAppEnvironment(original)
+ }
+ })
+
+ it('deduplicates originals and refuses the 65th distinct value before changing its marker', () => {
+ const service = new ManagedDataAccountService('private-original-root')
+ const first = managedContext(service, 'baseline-0')
+ for (let i = 0; i < 100; i++) {
+ expect(managedContext(service, 'baseline-0').ORCA_DATA_ACCOUNT_ORIGINAL_ENV).toBe(
+ first.ORCA_DATA_ACCOUNT_ORIGINAL_ENV
+ )
+ }
+ for (let i = 1; i < 64; i++) {
+ managedContext(service, `baseline-${i}`)
+ }
+ const overflow = { OPENCODE_AUTH_CONTENT: 'overflow-baseline' }
+ const before = hash(JSON.stringify(overflow))
+ expect(() => service.captureOriginalEnvironment(overflow, selected)).toThrow('baseline limit')
+ expect(hash(JSON.stringify(overflow)) === before).toBe(true)
+ service.restoreOriginalEnvironment(first)
+ expect(hash(first.OPENCODE_AUTH_CONTENT) === hash('baseline-0')).toBe(true)
+ })
+
+ it('bounds both UTF-8 bytes and selection bindings without evicting originals', () => {
+ const service = new ManagedDataAccountService('private-original-root')
+ const oversized = { OPENCODE_AUTH_CONTENT: '€'.repeat(22_000) }
+ expect(() => service.captureOriginalEnvironment(oversized, selected)).toThrow('launch limit')
+ expect(Object.keys(oversized)).toEqual(['OPENCODE_AUTH_CONTENT'])
+ const first = managedContext(service, 'same-baseline')
+ for (let i = 1; i < 64; i++) {
+ managedContext(service, 'same-baseline', {
+ ...selected,
+ XDG_DATA_HOME: join(tmpdir(), `profile-${i}`, 'data')
+ })
+ }
+ expect(() =>
+ managedContext(service, 'same-baseline', {
+ ...selected,
+ XDG_DATA_HOME: join(tmpdir(), 'overflow', 'data')
+ })
+ ).toThrow('context limit')
+ service.restoreOriginalEnvironment(first)
+ expect(hash(first.OPENCODE_AUTH_CONTENT) === hash('same-baseline')).toBe(true)
+ })
+})
+
+describe('original marker compatibility', () => {
+ it.each(['{', '[]', '{}', '{"inlineAuthReference":"invalid"}'])(
+ 'clears only proven overrides for malformed %s',
+ (marker) => {
+ const env = managedContext(
+ new ManagedDataAccountService('private-original-root'),
+ 'private-baseline'
+ )
+ env.ORCA_DATA_ACCOUNT_ORIGINAL_ENV = marker
+ env.XDG_STATE_HOME = join(tmpdir(), 'independent-state')
+ restoreManagedDataAccountEnvironment(env)
+ expect(env.XDG_DATA_HOME).toBeUndefined()
+ expect(env.XDG_STATE_HOME).toBe(join(tmpdir(), 'independent-state'))
+ expect(env.OPENCODE_DB).toBe('opencode.db')
+ expect(env.ORCA_DATA_ACCOUNT_ORIGINAL_ENV).toBeUndefined()
+ }
+ )
+
+ it('restores a recognized old plaintext snapshot while stripping it on a foreign relay', () => {
+ const env = managedContext(
+ new ManagedDataAccountService('private-original-root'),
+ 'private-baseline'
+ )
+ env.ORCA_DATA_ACCOUNT_ORIGINAL_ENV = JSON.stringify({
+ XDG_DATA_HOME: join(tmpdir(), 'legacy-system-data'),
+ XDG_STATE_HOME: null,
+ OPENCODE_AUTH_CONTENT: 'legacy-inline',
+ OPENCODE_DB: 'legacy.db'
+ })
+ const relay = { ...env }
+ restoreManagedDataAccountEnvironment(env)
+ restoreManagedDataAccountEnvironment(relay, false)
+ expect(hash(env.OPENCODE_AUTH_CONTENT) === hash('legacy-inline')).toBe(true)
+ expect(env.OPENCODE_DB).toBe('legacy.db')
+ expect(relay.XDG_DATA_HOME).toBeUndefined()
+ expect(relay.OPENCODE_AUTH_CONTENT).toBeUndefined()
+ expect(relay.ORCA_DATA_ACCOUNT_ORIGINAL_ENV).toBeUndefined()
+ })
+
+ it('preserves unknown old-client metadata and explicit System values without owned markers', () => {
+ const service = new ManagedDataAccountService('private-original-root')
+ const env = managedContext(service, 'private-baseline')
+ env.ORCA_DATA_ACCOUNT_PROVIDER = 'future-provider'
+ env.ORCA_DATA_ACCOUNT_FUTURE_METADATA = 'opaque-metadata'
+ const before = hash(JSON.stringify(env))
+ service.restoreOriginalEnvironment(env)
+ expect(hash(JSON.stringify(env)) === before).toBe(true)
+ delete env.ORCA_DATA_ACCOUNT_DATA_HOME
+ env.OPENCODE_AUTH_CONTENT = 'explicit-system-inline'
+ service.restoreOriginalEnvironment(env)
+ expect(hash(env.OPENCODE_AUTH_CONTENT) === hash('explicit-system-inline')).toBe(true)
+ expect(env.ORCA_DATA_ACCOUNT_FUTURE_METADATA).toBe('opaque-metadata')
+ })
+})
diff --git a/src/main/managed-data-accounts/service.ts b/src/main/managed-data-accounts/service.ts
index 4549795f6eb0..5393b55ea0d8 100644
--- a/src/main/managed-data-accounts/service.ts
+++ b/src/main/managed-data-accounts/service.ts
@@ -9,6 +9,15 @@ import type {
ManagedDataAccountsState
} from '../../shared/managed-account-types'
import { captureDataAccountCredentials } from './credential-capture'
+import {
+ captureManagedDataAccountOriginalEnvironment,
+ restoreManagedDataAccountEnvironment
+} from '../../shared/managed-data-account-environment'
+
+const MAX_INLINE_AUTH_BASELINES = 64
+const MAX_INLINE_AUTH_BYTES = 64 * 1024
+
+type InlineAuthBaseline = { value: string; selections: Set }
const stateSchema = z.object({
accounts: z
@@ -27,6 +36,7 @@ const stateSchema = z.object({
export class ManagedDataAccountService {
private pending: Promise = Promise.resolve()
private readonly listeners = new Set<() => void>()
+ private readonly inlineAuthBaselines = new Map()
constructor(
private readonly root: string,
@@ -123,6 +133,61 @@ export class ManagedDataAccountService {
return [...selected, ...others].map((account) => this.profileEnvironment(provider, account.id))
}
+ captureOriginalEnvironment(
+ environment: Record,
+ selected: Record
+ ): void {
+ const value = environment.OPENCODE_AUTH_CONTENT
+ let reference: string | undefined
+ if (value && selected.OPENCODE_AUTH_CONTENT === '') {
+ if (Buffer.byteLength(value, 'utf8') > MAX_INLINE_AUTH_BYTES) {
+ throw new Error('Inline authentication exceeds the managed launch limit.')
+ }
+ const selection = JSON.stringify([selected.XDG_DATA_HOME, selected.XDG_STATE_HOME])
+ const existing = [...this.inlineAuthBaselines].find(([, entry]) => entry.value === value)
+ if (existing) {
+ const [id, entry] = existing
+ if (
+ !entry.selections.has(selection) &&
+ entry.selections.size >= MAX_INLINE_AUTH_BASELINES
+ ) {
+ throw new Error('Managed inline authentication context limit reached.')
+ }
+ entry.selections.add(selection)
+ reference = id
+ } else {
+ if (this.inlineAuthBaselines.size >= MAX_INLINE_AUTH_BASELINES) {
+ throw new Error('Managed inline authentication baseline limit reached.')
+ }
+ reference = randomUUID()
+ this.inlineAuthBaselines.set(reference, { value, selections: new Set([selection]) })
+ }
+ }
+ captureManagedDataAccountOriginalEnvironment(environment, reference)
+ }
+
+ restoreOriginalEnvironment(environment: Record): void {
+ // A copied reference needs the exact overlay captured by this host service.
+ const selection = JSON.stringify([
+ environment.ORCA_DATA_ACCOUNT_DATA_HOME,
+ environment.ORCA_DATA_ACCOUNT_STATE_HOME
+ ])
+ const ownsSelection =
+ environment.ORCA_DATA_ACCOUNT_PROVIDER === 'opencode' &&
+ environment.OPENCODE_AUTH_CONTENT === '' &&
+ environment.OPENCODE_DB === 'opencode.db' &&
+ environment.XDG_DATA_HOME === environment.ORCA_DATA_ACCOUNT_DATA_HOME &&
+ environment.XDG_STATE_HOME === environment.ORCA_DATA_ACCOUNT_STATE_HOME
+ restoreManagedDataAccountEnvironment(environment, true, (reference) => {
+ const baseline = this.inlineAuthBaselines.get(reference)
+ return ownsSelection && baseline?.selections.has(selection) ? baseline.value : undefined
+ })
+ }
+
+ clearInlineAuthBaselines(): void {
+ this.inlineAuthBaselines.clear()
+ }
+
private profileEnvironment(
provider: ManagedDataAccountProvider,
accountId: string
@@ -185,11 +250,20 @@ export class ManagedDataAccountService {
}
let instance: { root: string; service: ManagedDataAccountService } | undefined
+let shutdownHookInstalled = false
export function getManagedDataAccountService(): ManagedDataAccountService {
const root = resolve(getAppEnvironment().getPath('userData'), 'managed-data-accounts')
if (instance?.root !== root) {
+ instance?.service.clearInlineAuthBaselines()
instance = { root, service: new ManagedDataAccountService(root) }
}
+ if (!shutdownHookInstalled) {
+ getAppEnvironment().onWillQuit(() => {
+ instance?.service.clearInlineAuthBaselines()
+ instance = undefined
+ })
+ shutdownHookInstalled = true
+ }
return instance.service
}
diff --git a/src/shared/managed-data-account-environment.ts b/src/shared/managed-data-account-environment.ts
index 8bfeca0af8da..313324533a17 100644
--- a/src/shared/managed-data-account-environment.ts
+++ b/src/shared/managed-data-account-environment.ts
@@ -4,7 +4,8 @@ const originalEnvironment = z.object({
XDG_DATA_HOME: z.string().nullable(),
XDG_STATE_HOME: z.string().nullable(),
OPENCODE_AUTH_CONTENT: z.string().nullable(),
- OPENCODE_DB: z.string().nullable()
+ OPENCODE_DB: z.string().nullable(),
+ inlineAuthReference: z.uuid().optional()
})
const ORIGINAL_ENV = 'ORCA_DATA_ACCOUNT_ORIGINAL_ENV'
export const MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS = [
@@ -14,32 +15,29 @@ export const MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS = [
'OPENCODE_DB'
] as const
-export function getInheritedManagedDataAccountEnvKeysToDelete(
- environment: Record | undefined
-): string[] {
- return [
- ...MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS,
- 'ORCA_DATA_ACCOUNT_DATA_HOME',
- 'ORCA_DATA_ACCOUNT_STATE_HOME',
- 'ORCA_DATA_ACCOUNT_PROVIDER',
- ORIGINAL_ENV
- ].filter((key) => environment?.[key] === undefined)
-}
-
export function captureManagedDataAccountOriginalEnvironment(
- environment: Record
+ environment: Record,
+ inlineAuthReference?: string
): void {
- environment[ORIGINAL_ENV] = JSON.stringify(
- Object.fromEntries(
+ environment[ORIGINAL_ENV] = JSON.stringify({
+ ...Object.fromEntries(
MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS.map((key) => [key, environment[key] ?? null])
- )
- )
+ ),
+ OPENCODE_AUTH_CONTENT: environment.OPENCODE_AUTH_CONTENT === '' ? '' : null,
+ ...(inlineAuthReference ? { inlineAuthReference } : {})
+ })
}
export function restoreManagedDataAccountEnvironment(
environment: Record,
- restoreOriginal = true
+ restoreOriginal = true,
+ resolveInlineAuth?: (reference: string) => string | undefined
): void {
+ const provider = environment.ORCA_DATA_ACCOUNT_PROVIDER
+ const dataHome = environment.ORCA_DATA_ACCOUNT_DATA_HOME
+ if (!dataHome || (provider !== undefined && provider !== 'opencode' && provider !== 'devin')) {
+ return
+ }
let original: z.infer | undefined
try {
const parsed = originalEnvironment.safeParse(JSON.parse(environment[ORIGINAL_ENV] ?? 'null'))
@@ -49,6 +47,15 @@ export function restoreManagedDataAccountEnvironment(
} catch {
// Older panes have no baseline snapshot; strip only their owned overrides.
}
+ const ownsOpenCode =
+ provider === 'opencode' &&
+ environment.XDG_DATA_HOME === dataHome &&
+ environment.ORCA_DATA_ACCOUNT_STATE_HOME !== undefined &&
+ environment.XDG_STATE_HOME === environment.ORCA_DATA_ACCOUNT_STATE_HOME
+ const inlineAuth =
+ ownsOpenCode && environment.OPENCODE_AUTH_CONTENT === '' && original?.inlineAuthReference
+ ? resolveInlineAuth?.(original.inlineAuthReference)
+ : original?.OPENCODE_AUTH_CONTENT
function restore(
key: (typeof MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS)[number],
ownedValue: string | undefined
@@ -56,20 +63,18 @@ export function restoreManagedDataAccountEnvironment(
if (ownedValue === undefined || environment[key] !== ownedValue) {
return
}
- const value = original?.[key]
+ const value = key === 'OPENCODE_AUTH_CONTENT' ? inlineAuth : original?.[key]
if (typeof value === 'string') {
environment[key] = value
} else {
delete environment[key]
}
}
- if (environment.ORCA_DATA_ACCOUNT_DATA_HOME) {
- restore('XDG_DATA_HOME', environment.ORCA_DATA_ACCOUNT_DATA_HOME)
- restore('XDG_STATE_HOME', environment.ORCA_DATA_ACCOUNT_STATE_HOME)
- if (environment.ORCA_DATA_ACCOUNT_PROVIDER === 'opencode') {
- restore('OPENCODE_AUTH_CONTENT', '')
- restore('OPENCODE_DB', 'opencode.db')
- }
+ restore('XDG_DATA_HOME', dataHome)
+ restore('XDG_STATE_HOME', environment.ORCA_DATA_ACCOUNT_STATE_HOME)
+ if (ownsOpenCode) {
+ restore('OPENCODE_AUTH_CONTENT', '')
+ restore('OPENCODE_DB', 'opencode.db')
}
delete environment.ORCA_DATA_ACCOUNT_DATA_HOME
delete environment.ORCA_DATA_ACCOUNT_STATE_HOME
From c49896e1d4cfd50893e92b196f3f31814bed59cb Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 06:35:10 -0700
Subject: [PATCH 14/24] Align account enrollment help with accepted providers
and flags
---
src/cli/handlers/account.test.ts | 4 +++-
src/cli/handlers/account.ts | 4 ++--
src/cli/specs/account.test.ts | 1 +
src/cli/specs/account.ts | 3 ++-
4 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/src/cli/handlers/account.test.ts b/src/cli/handlers/account.test.ts
index 9cecb6f12e4a..8b2fe9d5ae7e 100644
--- a/src/cli/handlers/account.test.ts
+++ b/src/cli/handlers/account.test.ts
@@ -715,7 +715,9 @@ describe('account CLI handlers', () => {
// default would run a full OAuth login for the wrong provider.
await expect(
ACCOUNT_HANDLERS['account add']({ ...context('claude'), flags: new Map([['agent', true]]) })
- ).rejects.toThrow('Missing a value for --agent')
+ ).rejects.toThrow(
+ 'Missing a value for --agent. Use `--agent claude`, `--agent codex`, `--agent opencode`, or `--agent devin`.'
+ )
expect(spawnMock).not.toHaveBeenCalled()
})
diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts
index 2fdc394b87ea..19d7cac1b49f 100644
--- a/src/cli/handlers/account.ts
+++ b/src/cli/handlers/account.ts
@@ -275,7 +275,7 @@ async function assertAccountImportSupported({ client }: HandlerContext): Promise
}
}
-/** CLI handlers for `orca account add [--agent claude|codex]` and `orca account list`. */
+/** CLI handlers for managed account enrollment and listing. */
export const ACCOUNT_HANDLERS: Record = {
'account add': async (ctx) => {
const agentFlag = ctx.flags.get('agent')
@@ -284,7 +284,7 @@ export const ACCOUNT_HANDLERS: Record = {
if (agentFlag !== undefined && typeof agentFlag !== 'string') {
throw new RuntimeClientError(
'invalid_argument',
- 'Missing a value for --agent. Use `--agent claude` or `--agent codex`.'
+ 'Missing a value for --agent. Use `--agent claude`, `--agent codex`, `--agent opencode`, or `--agent devin`.'
)
}
const agent = agentFlag ?? 'claude'
diff --git a/src/cli/specs/account.test.ts b/src/cli/specs/account.test.ts
index 91dc2cf48d50..1ab5d61b116b 100644
--- a/src/cli/specs/account.test.ts
+++ b/src/cli/specs/account.test.ts
@@ -35,6 +35,7 @@ describe('account command specs', () => {
expect(help).toContain('Account provider: claude, codex, opencode, or devin (default claude)')
expect(help).not.toContain('TUI agent')
+ expect(spec('account add').usage).toContain('[--integration ]')
})
it('aligns the --agent description with the global flag descriptions', () => {
diff --git a/src/cli/specs/account.ts b/src/cli/specs/account.ts
index 0f1cc0306ec0..0411bdbd1d06 100644
--- a/src/cli/specs/account.ts
+++ b/src/cli/specs/account.ts
@@ -9,7 +9,8 @@ export const ACCOUNT_COMMAND_SPECS: CommandSpec[] = [
{
path: ['account', 'add'],
summary: 'Add a managed agent account by signing in on this Orca host',
- usage: 'orca account add [--agent claude|codex|opencode|devin] [--label ] [--json]',
+ usage:
+ 'orca account add [--agent claude|codex|opencode|devin] [--label ] [--integration ] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'agent', 'label', 'integration'],
notes: [
'Runs the agent login (`claude login` / `codex login`) in this terminal, then registers the account with the local Orca runtime.',
From ed29c539030352662603c50c8b42b1405b8502c4 Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 06:36:13 -0700
Subject: [PATCH 15/24] Show the active System account in managed profile lists
---
src/cli/handlers/account-list-format.ts | 6 ++++-
.../handlers/data-account-commands.test.ts | 26 ++++++++++++++++++-
2 files changed, 30 insertions(+), 2 deletions(-)
diff --git a/src/cli/handlers/account-list-format.ts b/src/cli/handlers/account-list-format.ts
index 2b76d7a0f1e8..66da6d4b1b71 100644
--- a/src/cli/handlers/account-list-format.ts
+++ b/src/cli/handlers/account-list-format.ts
@@ -12,7 +12,11 @@ type AccountsBlock = {
}
export function formatDataAccounts(label: string, state: ManagedDataAccountsState): string {
- return `Managed ${label} accounts (${state.accounts.length}):\n${state.accounts
+ const system = ` system System default${state.activeAccountId === null ? ' (active)' : ''}`
+ if (state.accounts.length === 0) {
+ return `No managed ${label} accounts.\n${system}`
+ }
+ return `Managed ${label} accounts (${state.accounts.length}):\n${system}\n${state.accounts
.map(
(account) =>
` ${account.id} ${account.label}${account.id === state.activeAccountId ? ' (active)' : ''}`
diff --git a/src/cli/handlers/data-account-commands.test.ts b/src/cli/handlers/data-account-commands.test.ts
index a440b9ccf88a..6dd4b2e23623 100644
--- a/src/cli/handlers/data-account-commands.test.ts
+++ b/src/cli/handlers/data-account-commands.test.ts
@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { RuntimeClient } from '../runtime-client'
import { DATA_ACCOUNT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
-import { addDataAccount } from './data-account-commands'
+import { addDataAccount, listDataAccounts } from './data-account-commands'
const client = new RuntimeClient(join(tmpdir(), 'orca-login-test'), 1000, null, null)
const context = {
@@ -18,6 +18,30 @@ const context = {
afterEach(() => vi.restoreAllMocks())
describe('managed data account enrollment', () => {
+ it.each(['opencode', 'devin'])(
+ 'shows the active System default for an empty %s roster',
+ async (provider) => {
+ vi.spyOn(client, 'call')
+ .mockResolvedValueOnce({
+ id: 'test',
+ ok: true,
+ result: { capabilities: [DATA_ACCOUNT_RUNTIME_CAPABILITY] },
+ _meta: { runtimeId: 'test' }
+ })
+ .mockResolvedValueOnce({
+ id: 'test',
+ ok: true,
+ result: { [provider]: { accounts: [], activeAccountId: null } },
+ _meta: { runtimeId: 'test' }
+ })
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {})
+ await listDataAccounts({ ...context, json: false }, provider)
+ expect(log).toHaveBeenCalledWith(
+ `No managed ${provider} accounts.\n system System default (active)`
+ )
+ }
+ )
+
it('refuses an old host before starting login', async () => {
vi.spyOn(client, 'call').mockResolvedValue({
id: 'test',
From 4d92716a69c9ac2b95d46397403ec57326c73dca Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 06:36:32 -0700
Subject: [PATCH 16/24] Document the validated Linux managed account scope
---
docs/reference/managed-data-accounts.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/reference/managed-data-accounts.md b/docs/reference/managed-data-accounts.md
index a8db7c6762f9..7967ab231e5a 100644
--- a/docs/reference/managed-data-accounts.md
+++ b/docs/reference/managed-data-accounts.md
@@ -20,4 +20,4 @@ Selection affects newly launched explicit OpenCode/Devin commands and agent laun
For SSH, enroll by running the command on a headless Orca runtime on the remote machine. The remote runtime owns its profiles and selection; a desktop client's credential paths never cross SSH. Direct SSH relay launches and Windows-hosted WSL panes do not consume the desktop host's profiles. Run a headless runtime inside that execution environment instead. Folder workspaces use the same host account store as git worktrees. Older Orca hosts reject new operations before login through capability negotiation.
-Validation currently covers OpenCode 2.0.16 on macOS, real isolated login and selected terminal authentication, Devin 3000.10.31 saved-login recognition, and the Node headless runtime. Fresh Devin manual-token enrollment, a physical SSH host, Linux, and Windows still require verification; these are not claimed as tested.
+Validation covers OpenCode 2.0.16 on macOS and Linux arm64, including isolated official enrollment, selected and System background-terminal credential checks, reselection, and profile deletion. Linux checks used the Node headless runtime in an Ubuntu 24.04 container. Devin 3000.10.31 saved-login recognition was checked on macOS. Fresh Devin manual-token enrollment, a physical SSH host, Linux desktop UI, Windows, and Windows-hosted WSL still require verification.
From 6645dae104ee5271139fcc0e9564d9223b74835d Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 06:53:21 -0700
Subject: [PATCH 17/24] fix(opencode): resolve Go keys by execution backend
---
.../opencode-database-discovery.ts | 14 +-
.../opencode-credential-backend.test.ts | 182 +++++++++++++++++
.../opencode/opencode-credential-backend.ts | 91 +++++++++
.../opencode-go-api-key-source.test.ts | 185 +++++++++++++++++-
.../rate-limits/opencode-go-api-key-source.ts | 72 +++++--
...opencode-go-usage-source-selection.test.ts | 38 ++++
.../opencode-go-usage-source-selection.ts | 14 +-
7 files changed, 568 insertions(+), 28 deletions(-)
create mode 100644 src/main/opencode/opencode-credential-backend.test.ts
create mode 100644 src/main/opencode/opencode-credential-backend.ts
diff --git a/src/main/opencode-usage/opencode-database-discovery.ts b/src/main/opencode-usage/opencode-database-discovery.ts
index fb406dc7a796..3525bfbdc84a 100644
--- a/src/main/opencode-usage/opencode-database-discovery.ts
+++ b/src/main/opencode-usage/opencode-database-discovery.ts
@@ -10,8 +10,11 @@ type OpenCodeDatabaseOverride = {
path: string | null
}
-function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOverride {
- const raw = process.env.OPENCODE_DB?.trim()
+function getOpenCodeDatabaseOverride(
+ dataDirectory: string,
+ environment: NodeJS.ProcessEnv
+): OpenCodeDatabaseOverride {
+ const raw = environment.OPENCODE_DB?.trim()
if (!raw) {
return { isConfigured: false, path: null }
}
@@ -30,10 +33,11 @@ function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOve
export async function listOpenCodeDatabases(
/** Lets a caller report the refusal; an empty list otherwise reads as
* "OpenCode not used" rather than "we could not look". */
- onRefusal?: (path: string, error: WslTranscriptFsError) => void
+ onRefusal?: (path: string, error: WslTranscriptFsError) => void,
+ environment: NodeJS.ProcessEnv = process.env
): Promise {
- const dataDirectory = resolveOpenCodeDataDirectory()
- const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory)
+ const dataDirectory = resolveOpenCodeDataDirectory(environment)
+ const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory, environment)
if (databaseOverride.isConfigured) {
if (!databaseOverride.path) {
return []
diff --git a/src/main/opencode/opencode-credential-backend.test.ts b/src/main/opencode/opencode-credential-backend.test.ts
new file mode 100644
index 000000000000..3bf00336eb10
--- /dev/null
+++ b/src/main/opencode/opencode-credential-backend.test.ts
@@ -0,0 +1,182 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { runProcess } from '../../shared/child-process/run-process'
+import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver'
+import {
+ detectOpenCodeCredentialBackend,
+ resetOpenCodeCredentialBackendProbes
+} from './opencode-credential-backend'
+
+const files = vi.hoisted(() => ({ realpath: vi.fn(), stat: vi.fn() }))
+
+vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() }))
+vi.mock('../ipc/command-path-resolver', () => ({ resolveCommandOnLocalPath: vi.fn() }))
+vi.mock('node:fs/promises', () => files)
+
+describe('OpenCode credential execution backend', () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ resetOpenCodeCredentialBackendProbes()
+ files.realpath.mockImplementation(async (path: string) => path)
+ files.stat.mockResolvedValue({ dev: 1, ino: 2, size: 3, mtimeMs: 4, ctimeMs: 5 })
+ vi.mocked(resolveCommandOnLocalPath).mockResolvedValue('/task/bin/opencode')
+ vi.mocked(runProcess).mockResolvedValue({
+ code: 0,
+ signal: null,
+ stdout: '1.18.30\n',
+ stderr: '',
+ timedOut: false
+ })
+ })
+
+ it.each([
+ ['1.18.30\n', 'v1'],
+ ['opencode v2.0.16\n', 'v2'],
+ ['2.0.16', 'v2'],
+ ['opencode v2.0.16-beta.1', 'v2'],
+ ['3.0.0', null],
+ ['wrapper 2.0.16', null],
+ ['', null]
+ ])('uses the reported backend for %j', async (stdout, backend) => {
+ vi.mocked(runProcess).mockResolvedValue({
+ code: 0,
+ signal: null,
+ stdout,
+ stderr: '',
+ timedOut: false
+ })
+
+ expect(await detectOpenCodeCredentialBackend()).toBe(backend)
+ })
+
+ it('resolves and executes the binary in the caller environment with a bounded probe', async () => {
+ const environment = { PATH: '/task/bin', XDG_DATA_HOME: '/task/data' }
+
+ await detectOpenCodeCredentialBackend(environment, '/task/workspace')
+
+ expect(resolveCommandOnLocalPath).toHaveBeenCalledExactlyOnceWith('opencode', {
+ env: environment,
+ cwd: '/task/workspace'
+ })
+ expect(runProcess).toHaveBeenCalledExactlyOnceWith({
+ program: '/task/bin/opencode',
+ args: ['--version'],
+ env: environment,
+ cwd: '/task/workspace',
+ timeoutMs: 5_000,
+ maxOutputBytes: 1_024
+ })
+ })
+
+ it('probes opencode2 only when the default opencode command is absent', async () => {
+ vi.mocked(resolveCommandOnLocalPath)
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce('/task/bin/opencode2')
+ vi.mocked(runProcess).mockResolvedValue({
+ code: 0,
+ signal: null,
+ stdout: 'opencode v2.0.16',
+ stderr: '',
+ timedOut: false
+ })
+
+ expect(await detectOpenCodeCredentialBackend()).toBe('v2')
+ expect(runProcess).toHaveBeenCalledWith(
+ expect.objectContaining({ program: '/task/bin/opencode2' })
+ )
+ })
+
+ it.each([
+ { code: 1, timedOut: false },
+ { code: 0, timedOut: true },
+ { code: 0, timedOut: false, outputTruncated: true }
+ ])('withholds authority when the installed probe fails: %j', async (failure) => {
+ vi.mocked(runProcess).mockResolvedValue({
+ ...failure,
+ signal: null,
+ stdout: 'opencode v2.0.16',
+ stderr: ''
+ })
+
+ expect(await detectOpenCodeCredentialBackend()).toBeNull()
+ expect(resolveCommandOnLocalPath).toHaveBeenCalledTimes(1)
+ })
+
+ it('withholds authority after a spawn error without substituting another CLI', async () => {
+ vi.mocked(runProcess).mockRejectedValue(new Error('unavailable'))
+
+ expect(await detectOpenCodeCredentialBackend()).toBeNull()
+ expect(resolveCommandOnLocalPath).toHaveBeenCalledTimes(1)
+ })
+
+ it('withholds authority when neither CLI is installed', async () => {
+ vi.mocked(resolveCommandOnLocalPath).mockResolvedValue(null)
+
+ expect(await detectOpenCodeCredentialBackend()).toBeNull()
+ expect(runProcess).not.toHaveBeenCalled()
+ })
+
+ it('coalesces concurrent calls and reuses a successful binary identity', async () => {
+ expect(
+ await Promise.all([
+ detectOpenCodeCredentialBackend(),
+ detectOpenCodeCredentialBackend(),
+ detectOpenCodeCredentialBackend()
+ ])
+ ).toEqual(['v1', 'v1', 'v1'])
+ expect(await detectOpenCodeCredentialBackend()).toBe('v1')
+ expect(runProcess).toHaveBeenCalledTimes(1)
+ })
+
+ it('reprobes when the resolved binary is replaced', async () => {
+ expect(await detectOpenCodeCredentialBackend()).toBe('v1')
+ files.stat.mockResolvedValue({ dev: 1, ino: 6, size: 3, mtimeMs: 7, ctimeMs: 8 })
+ vi.mocked(runProcess).mockResolvedValue({
+ code: 0,
+ signal: null,
+ stdout: 'opencode v2.0.16',
+ stderr: '',
+ timedOut: false
+ })
+
+ expect(await detectOpenCodeCredentialBackend()).toBe('v2')
+ expect(runProcess).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not reuse a probe across caller environments or working directories', async () => {
+ await detectOpenCodeCredentialBackend(
+ { PATH: '/task/bin', XDG_DATA_HOME: '/task/a' },
+ '/task/a'
+ )
+ await detectOpenCodeCredentialBackend(
+ { PATH: '/task/bin', XDG_DATA_HOME: '/task/b' },
+ '/task/a'
+ )
+ await detectOpenCodeCredentialBackend(
+ { PATH: '/task/bin', XDG_DATA_HOME: '/task/b' },
+ '/task/b'
+ )
+
+ expect(runProcess).toHaveBeenCalledTimes(3)
+ })
+
+ it('retries an unknown backend after its short cache expires', async () => {
+ const now = vi.spyOn(Date, 'now').mockReturnValue(1_000)
+ vi.mocked(runProcess).mockRejectedValueOnce(new Error('temporarily unavailable'))
+ try {
+ expect(await detectOpenCodeCredentialBackend()).toBeNull()
+ expect(await detectOpenCodeCredentialBackend()).toBeNull()
+ now.mockReturnValue(6_001)
+ expect(await detectOpenCodeCredentialBackend()).toBe('v1')
+ expect(runProcess).toHaveBeenCalledTimes(2)
+ } finally {
+ now.mockRestore()
+ }
+ })
+
+ it('withholds authority when the selected binary identity cannot be read', async () => {
+ files.stat.mockRejectedValue(new Error('unreadable executable'))
+
+ expect(await detectOpenCodeCredentialBackend()).toBeNull()
+ expect(runProcess).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/main/opencode/opencode-credential-backend.ts b/src/main/opencode/opencode-credential-backend.ts
new file mode 100644
index 000000000000..69c860dbe48d
--- /dev/null
+++ b/src/main/opencode/opencode-credential-backend.ts
@@ -0,0 +1,91 @@
+import { runProcess } from '../../shared/child-process/run-process'
+import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver'
+import { createHash } from 'node:crypto'
+import { realpath, stat } from 'node:fs/promises'
+
+export type OpenCodeCredentialBackend = 'v1' | 'v2'
+
+// Native runtime processes own separate execution-host caches.
+const probes = new Map<
+ string,
+ { result: Promise; expiresAt: number }
+>()
+const MAX_PROBES = 32
+
+export function resetOpenCodeCredentialBackendProbes(): void {
+ probes.clear()
+}
+
+export async function detectOpenCodeCredentialBackend(
+ environment: NodeJS.ProcessEnv = process.env,
+ cwd = process.cwd()
+): Promise {
+ // The execution host's default CLI owns this lookup; table presence proves neither backend.
+ const program =
+ (await resolveCommandOnLocalPath('opencode', { env: environment, cwd })) ??
+ (await resolveCommandOnLocalPath('opencode2', { env: environment, cwd }))
+ if (!program) {
+ return null
+ }
+ try {
+ const binary = await realpath(program)
+ const identity = await stat(binary)
+ const environmentDigest = createHash('sha256')
+ .update(JSON.stringify(Object.entries(environment).sort(([a], [b]) => a.localeCompare(b))))
+ .digest('hex')
+ const key = JSON.stringify([
+ binary,
+ identity.dev,
+ identity.ino,
+ identity.size,
+ identity.mtimeMs,
+ identity.ctimeMs,
+ cwd,
+ environmentDigest
+ ])
+ const cached = probes.get(key)
+ if (cached && cached.expiresAt > Date.now()) {
+ return cached.result
+ }
+ const result = probeBackend(binary, environment, cwd)
+ const entry = { result, expiresAt: Number.POSITIVE_INFINITY }
+ probes.set(key, entry)
+ if (probes.size > MAX_PROBES) {
+ const oldest = probes.keys().next().value
+ if (oldest !== undefined) {
+ probes.delete(oldest)
+ }
+ }
+ const backend = await result
+ entry.expiresAt = Date.now() + (backend ? 60_000 : 5_000)
+ return backend
+ } catch {
+ return null
+ }
+}
+
+async function probeBackend(
+ program: string,
+ environment: NodeJS.ProcessEnv,
+ cwd: string
+): Promise {
+ try {
+ const result = await runProcess({
+ program,
+ args: ['--version'],
+ env: environment,
+ cwd,
+ timeoutMs: 5_000,
+ maxOutputBytes: 1_024
+ })
+ if (result.code !== 0 || result.timedOut || result.outputTruncated) {
+ return null
+ }
+ const version = /^(?:opencode\s+)?v?([12])\.\d+\.\d+(?:[-+][\w.-]+)?$/i.exec(
+ result.stdout.trim()
+ )
+ return version?.[1] === '1' ? 'v1' : version?.[1] === '2' ? 'v2' : null
+ } catch {
+ return null
+ }
+}
diff --git a/src/main/rate-limits/opencode-go-api-key-source.test.ts b/src/main/rate-limits/opencode-go-api-key-source.test.ts
index b463cea5b9ff..32ef11ae1201 100644
--- a/src/main/rate-limits/opencode-go-api-key-source.test.ts
+++ b/src/main/rate-limits/opencode-go-api-key-source.test.ts
@@ -1,21 +1,31 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Database from '../sqlite/sync-database'
+import { detectOpenCodeCredentialBackend } from '../opencode/opencode-credential-backend'
import {
getOpenCodeAuthFilePath,
readOpenCodeAuthFileGoKey,
resolveOpenCodeGoApiKey
} from './opencode-go-api-key-source'
+vi.mock('../opencode/opencode-credential-backend', () => ({
+ detectOpenCodeCredentialBackend: vi.fn()
+}))
+
// Placeholder values only — a real key must never reach a fixture.
const SETTINGS_KEY = 'settings-placeholder-key'
const ENVIRONMENT_KEY = 'environment-placeholder-key'
const AUTH_FILE_KEY = 'auth-file-placeholder-key'
const DATABASE_KEY = 'database-placeholder-key'
-const ENVIRONMENT_KEYS = ['XDG_DATA_HOME', 'OPENCODE_API_KEY', 'OPENCODE_DB'] as const
+const ENVIRONMENT_KEYS = [
+ 'XDG_DATA_HOME',
+ 'OPENCODE_API_KEY',
+ 'OPENCODE_DB',
+ 'OPENCODE_AUTH_CONTENT'
+] as const
describe('resolveOpenCodeGoApiKey', () => {
let dataHome: string
@@ -48,10 +58,12 @@ describe('resolveOpenCodeGoApiKey', () => {
}
beforeEach(() => {
+ vi.mocked(detectOpenCodeCredentialBackend).mockReset().mockResolvedValue('v2')
originalEnvironment = Object.fromEntries(ENVIRONMENT_KEYS.map((key) => [key, process.env[key]]))
dataHome = mkdtempSync(join(tmpdir(), 'orca-opencode-go-key-'))
process.env.XDG_DATA_HOME = dataHome
delete process.env.OPENCODE_API_KEY
+ delete process.env.OPENCODE_AUTH_CONTENT
// Keeps the credential-database tier from touching the developer's own store.
process.env.OPENCODE_DB = ':memory:'
})
@@ -107,13 +119,13 @@ describe('resolveOpenCodeGoApiKey', () => {
})
})
- it('falls back to the key OpenCode 1.x saved on /connect when the table is empty', async () => {
+ it('uses the key OpenCode 1.x saved on /connect', async () => {
writeAuthFile({
anthropic: { type: 'oauth', refresh: 'r', access: 'a', expires: 1 },
'opencode-go': { type: 'api', key: AUTH_FILE_KEY }
})
- await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({
+ await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({
status: 'found',
key: AUTH_FILE_KEY,
tier: 'opencode-auth-file'
@@ -154,6 +166,171 @@ describe('resolveOpenCodeGoApiKey', () => {
})
})
+ it('uses v1 auth.json despite a conflicting populated credential table', async () => {
+ process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY
+ writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
+ const { path } = writeCredentialDatabase([
+ { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
+ ])
+ process.env.OPENCODE_DB = path
+
+ await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({
+ status: 'found',
+ key: AUTH_FILE_KEY,
+ tier: 'opencode-auth-file'
+ })
+ })
+
+ it('does not use a v2 table-only credential for v1 execution', async () => {
+ process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY
+ const { path } = writeCredentialDatabase([
+ { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
+ ])
+ process.env.OPENCODE_DB = path
+
+ await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({
+ status: 'found',
+ key: ENVIRONMENT_KEY,
+ tier: 'environment'
+ })
+ })
+
+ it('applies the installed v1 backend even when the table has a key', async () => {
+ vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue('v1')
+ writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
+ process.env.OPENCODE_DB = writeCredentialDatabase([
+ { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
+ ]).path
+
+ await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({
+ status: 'found',
+ key: AUTH_FILE_KEY,
+ tier: 'opencode-auth-file'
+ })
+ })
+
+ it('uses v1 inline authentication ahead of a conflicting auth file and table', async () => {
+ writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
+ process.env.OPENCODE_DB = writeCredentialDatabase([
+ { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
+ ]).path
+ process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({
+ 'opencode-go': { type: 'api', key: 'inline-placeholder-key' }
+ })
+
+ await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({
+ status: 'found',
+ key: 'inline-placeholder-key',
+ tier: 'opencode-auth-content'
+ })
+ })
+
+ it('does not fall back to the auth file when valid v1 inline auth omits Go', async () => {
+ writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
+ process.env.OPENCODE_AUTH_CONTENT = '{}'
+ process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY
+
+ await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({
+ status: 'found',
+ key: ENVIRONMENT_KEY,
+ tier: 'environment'
+ })
+ })
+
+ it('uses the v1 auth file after malformed inline authentication', async () => {
+ writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
+ process.env.OPENCODE_AUTH_CONTENT = '{invalid'
+
+ await expect(resolveOpenCodeGoApiKey({ backend: 'v1' })).resolves.toEqual({
+ status: 'found',
+ key: AUTH_FILE_KEY,
+ tier: 'opencode-auth-file'
+ })
+ })
+
+ it('withholds stored credentials when the execution backend cannot be determined', async () => {
+ vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue(null)
+ writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
+ process.env.OPENCODE_DB = writeCredentialDatabase([
+ { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
+ ]).path
+
+ await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ status: 'missing' })
+ })
+
+ it('returns the manual override before reading execution context or probing a CLI', async () => {
+ const input = { settingsOverride: SETTINGS_KEY }
+ Object.defineProperty(input, 'environment', {
+ get: () => {
+ throw new Error('Selected profile metadata is unreadable')
+ }
+ })
+ vi.mocked(detectOpenCodeCredentialBackend).mockRejectedValue(new Error('Unexpected probe'))
+
+ await expect(resolveOpenCodeGoApiKey(input)).resolves.toEqual({
+ status: 'found',
+ key: SETTINGS_KEY,
+ tier: 'settings'
+ })
+ expect(detectOpenCodeCredentialBackend).not.toHaveBeenCalled()
+ })
+
+ it('propagates unreadable selected context instead of reading host credentials', async () => {
+ writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
+ const input = {}
+ Object.defineProperty(input, 'environment', {
+ get: () => {
+ throw new Error('Selected profile metadata is unreadable')
+ }
+ })
+
+ await expect(resolveOpenCodeGoApiKey(input)).rejects.toThrow(
+ 'Selected profile metadata is unreadable'
+ )
+ })
+
+ it.each(['v1', 'v2'] as const)(
+ 'keeps System and selected data roots isolated for %s',
+ async (backend) => {
+ writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } })
+ const system = { XDG_DATA_HOME: dataHome, OPENCODE_DB: ':memory:' }
+ const selectedDataHome = join(dataHome, 'selected')
+ mkdirSync(join(selectedDataHome, 'opencode'), { recursive: true })
+ writeFileSync(
+ join(selectedDataHome, 'opencode', 'auth.json'),
+ JSON.stringify({
+ 'opencode-go': { type: 'api', key: 'selected-auth-placeholder' }
+ })
+ )
+ const { path } = writeCredentialDatabase([
+ {
+ value: JSON.stringify({ type: 'key', key: 'selected-table-placeholder' }),
+ active: 1,
+ created: 1
+ }
+ ])
+ const selected = { XDG_DATA_HOME: selectedDataHome, OPENCODE_DB: path }
+ const inherited = { ...process.env }
+
+ const first = await resolveOpenCodeGoApiKey({ environment: system, backend })
+ const managed = await resolveOpenCodeGoApiKey({ environment: selected, backend })
+ const restored = await resolveOpenCodeGoApiKey({ environment: system, backend })
+
+ expect(first).toEqual({ status: 'found', key: AUTH_FILE_KEY, tier: 'opencode-auth-file' })
+ expect(managed).toEqual(
+ backend === 'v1'
+ ? { status: 'found', key: 'selected-auth-placeholder', tier: 'opencode-auth-file' }
+ : {
+ status: 'found',
+ key: 'selected-table-placeholder',
+ tier: 'opencode-credential-database'
+ }
+ )
+ expect(restored).toEqual(first)
+ expect(process.env).toEqual(inherited)
+ }
+ )
+
it('keeps the settings override above the credential table', async () => {
const { path } = writeCredentialDatabase([
{ value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 }
diff --git a/src/main/rate-limits/opencode-go-api-key-source.ts b/src/main/rate-limits/opencode-go-api-key-source.ts
index eecfeec1ea7a..ec0af29acf7d 100644
--- a/src/main/rate-limits/opencode-go-api-key-source.ts
+++ b/src/main/rate-limits/opencode-go-api-key-source.ts
@@ -8,6 +8,10 @@ import { tableExists } from '../opencode-usage/schema-helpers'
import { isWslUncPath } from '../../shared/wsl-paths'
import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory'
import Database from '../sqlite/sync-database'
+import {
+ detectOpenCodeCredentialBackend,
+ type OpenCodeCredentialBackend
+} from '../opencode/opencode-credential-backend'
/** OpenCode's provider/integration id for the Go subscription. */
const OPENCODE_GO_INTEGRATION_ID = 'opencode-go'
@@ -20,6 +24,7 @@ const MAX_AUTH_FILE_BYTES = 1_000_000
export type OpenCodeGoApiKeyTier =
| 'settings'
| 'environment'
+ | 'opencode-auth-content'
| 'opencode-auth-file'
| 'opencode-credential-database'
@@ -54,6 +59,27 @@ function trimmedKey(value: unknown): string | null {
return trimmed ? trimmed : null
}
+function readOpenCodeInlineGoKey(environment: NodeJS.ProcessEnv): {
+ configured: boolean
+ key: string | null
+} {
+ const content = environment.OPENCODE_AUTH_CONTENT
+ if (content) {
+ try {
+ const parsed: unknown = JSON.parse(content)
+ return {
+ configured: true,
+ key: isRecord(parsed)
+ ? keyFromCredentialRecord(parsed[OPENCODE_GO_INTEGRATION_ID], 'api')
+ : null
+ }
+ } catch {
+ // V1 ignores invalid inline JSON and then reads auth.json.
+ }
+ }
+ return { configured: false, key: null }
+}
+
/**
* Read the `opencode-go` API key OpenCode 1.x writes on `/connect`.
*
@@ -80,8 +106,6 @@ export function readOpenCodeAuthFileGoKey(
}
return keyFromCredentialRecord(parsed[OPENCODE_GO_INTEGRATION_ID], 'api')
} catch {
- // Why: a malformed or unreadable auth file is "no key here", not a fetch
- // failure — later tiers and the cookie path still deserve their turn.
return null
}
}
@@ -120,14 +144,18 @@ function selectCredentialKey(database: Database.Database): string | null {
* OpenCode 2 imports `auth.json` into SQLite once (migration
* `20260805200742_import_legacy_credentials`) and every later `/connect` writes
* only there, so a fresh OpenCode 2 install has no `auth.json` entry at all.
- * The table itself is not a version marker — 1.18.x creates it too (verified
- * empty on a real 1.18.16 install), so probe it regardless of version.
+ * V1 also creates this table and can populate it through integration routes;
+ * only a caller with V2 execution authority should use it for Go credentials.
* @returns The key, or null when no database, table, or row carries one.
*/
-export async function readOpenCodeCredentialDatabaseGoKey(): Promise {
+export async function readOpenCodeCredentialDatabaseGoKey(
+ environment: NodeJS.ProcessEnv = process.env
+): Promise {
let paths: string[]
try {
- paths = [...(await listOpenCodeDatabases())].sort(compareOpenCodeClaimPriority)
+ paths = [...(await listOpenCodeDatabases(undefined, environment))].sort(
+ compareOpenCodeClaimPriority
+ )
} catch {
return null
}
@@ -159,13 +187,9 @@ export async function readOpenCodeCredentialDatabaseGoKey(): Promise {
- const environment = input.environment ?? process.env
const override = trimmedKey(input.settingsOverride)
if (override) {
return { status: 'found', key: override, tier: 'settings' }
}
- const fromDatabase = await readOpenCodeCredentialDatabaseGoKey()
- if (fromDatabase) {
- return { status: 'found', key: fromDatabase, tier: 'opencode-credential-database' }
+ const environment = input.environment ?? process.env
+ const backend = input.backend ?? (await detectOpenCodeCredentialBackend(environment, input.cwd))
+ if (!backend) {
+ return { status: 'missing' }
+ }
+ if (backend === 'v2') {
+ const fromDatabase = await readOpenCodeCredentialDatabaseGoKey(environment)
+ if (fromDatabase) {
+ return { status: 'found', key: fromDatabase, tier: 'opencode-credential-database' }
+ }
+ }
+ const inline = backend === 'v1' ? readOpenCodeInlineGoKey(environment) : null
+ if (inline?.key) {
+ return { status: 'found', key: inline.key, tier: 'opencode-auth-content' }
}
- const fromAuthFile = readOpenCodeAuthFileGoKey(environment)
+ const fromAuthFile = inline?.configured ? null : readOpenCodeAuthFileGoKey(environment)
if (fromAuthFile) {
return { status: 'found', key: fromAuthFile, tier: 'opencode-auth-file' }
}
diff --git a/src/main/rate-limits/opencode-go-usage-source-selection.test.ts b/src/main/rate-limits/opencode-go-usage-source-selection.test.ts
index c907705b45e2..7c4372b65334 100644
--- a/src/main/rate-limits/opencode-go-usage-source-selection.test.ts
+++ b/src/main/rate-limits/opencode-go-usage-source-selection.test.ts
@@ -76,6 +76,44 @@ describe('fetchOpenCodeGoUsage', () => {
expect(resolveApiKeyMock).toHaveBeenCalledWith({ settingsOverride: API_KEY })
})
+ it('passes trusted execution context and selected roots through to credential lookup', async () => {
+ resolveApiKeyMock.mockResolvedValue({ status: 'missing' })
+ const environment = { XDG_DATA_HOME: '/task/selected', OPENCODE_DB: ':memory:' }
+
+ await fetchOpenCodeGoUsage({ cookie: '', backend: 'v1', environment, cwd: '/task/workspace' })
+
+ expect(resolveApiKeyMock).toHaveBeenCalledExactlyOnceWith({
+ settingsOverride: undefined,
+ backend: 'v1',
+ environment,
+ cwd: '/task/workspace'
+ })
+ })
+
+ it('passes a manual override without evaluating selected execution context', async () => {
+ resolveApiKeyMock.mockResolvedValue({ status: 'found', key: API_KEY, tier: 'settings' })
+ fetchWithApiKeyMock.mockResolvedValue({ kind: 'ok', windows: WINDOWS })
+ const input = { cookie: '', settingsApiKey: API_KEY }
+ Object.defineProperty(input, 'environment', {
+ get: () => {
+ throw new Error('Selected profile metadata is unreadable')
+ }
+ })
+
+ expect((await fetchOpenCodeGoUsage(input)).status).toBe('ok')
+ expect(resolveApiKeyMock).toHaveBeenCalledExactlyOnceWith({ settingsOverride: API_KEY })
+ })
+
+ it('propagates a rejected selected-account lookup without trying host or cookie credentials', async () => {
+ resolveApiKeyMock.mockRejectedValue(new Error('Selected profile metadata is unreadable'))
+
+ await expect(fetchOpenCodeGoUsage({ cookie: COOKIE })).rejects.toThrow(
+ 'Selected profile metadata is unreadable'
+ )
+ expect(fetchWithApiKeyMock).not.toHaveBeenCalled()
+ expect(fetchWithCookieMock).not.toHaveBeenCalled()
+ })
+
it('names the missing subscription instead of a generic refresh failure', async () => {
resolveApiKeyMock.mockResolvedValue({
status: 'found',
diff --git a/src/main/rate-limits/opencode-go-usage-source-selection.ts b/src/main/rate-limits/opencode-go-usage-source-selection.ts
index 9d5399a585ed..994ff2d77028 100644
--- a/src/main/rate-limits/opencode-go-usage-source-selection.ts
+++ b/src/main/rate-limits/opencode-go-usage-source-selection.ts
@@ -5,6 +5,7 @@ import {
type OpenCodeGoApiKeyResolution
} from './opencode-go-api-key-source'
import type { OpenCodeGoUsageWindows } from './opencode-go-status-parsing'
+import type { OpenCodeCredentialBackend } from '../opencode/opencode-credential-backend'
import {
fetchOpenCodeGoUsageWithApiKey,
type OpenCodeGoUsageApiOutcome
@@ -14,6 +15,9 @@ import { fetchOpenCodeGoRateLimits, normalizeCookieInput } from './opencode-go-u
export type OpenCodeGoUsageSourceInput = {
/** Explicit Orca override; the highest-precedence key tier. */
settingsApiKey?: string
+ environment?: NodeJS.ProcessEnv
+ backend?: OpenCodeCredentialBackend
+ cwd?: string
cookie: string
workspaceIdOverride?: string
networkProxySettings?: NetworkProxySettings
@@ -96,8 +100,16 @@ function apiFailureResult(
export async function fetchOpenCodeGoUsage(
input: OpenCodeGoUsageSourceInput
): Promise {
+ const context = input.settingsApiKey?.trim()
+ ? {}
+ : {
+ ...(input.environment ? { environment: input.environment } : {}),
+ ...(input.backend ? { backend: input.backend } : {}),
+ ...(input.cwd ? { cwd: input.cwd } : {})
+ }
const apiKeyResolution = await resolveOpenCodeGoApiKey({
- settingsOverride: input.settingsApiKey
+ settingsOverride: input.settingsApiKey,
+ ...context
})
input.onApiKeyResolved?.(apiKeyResolution)
const hasCookie = Boolean(normalizeCookieInput(input.cookie))
From 2a8a26c03d87cd98ee5682b4ad758172f5e1dc08 Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 07:57:38 -0700
Subject: [PATCH 18/24] Use host-private account restoration for OpenCode Go
usage
---
.../rate-limits/opencode-go-api-key-source.ts | 8 +-
...opencode-go-managed-account-source.test.ts | 241 ++++++++++++++++++
2 files changed, 248 insertions(+), 1 deletion(-)
create mode 100644 src/main/rate-limits/opencode-go-managed-account-source.test.ts
diff --git a/src/main/rate-limits/opencode-go-api-key-source.ts b/src/main/rate-limits/opencode-go-api-key-source.ts
index ec0af29acf7d..1e65c445e89e 100644
--- a/src/main/rate-limits/opencode-go-api-key-source.ts
+++ b/src/main/rate-limits/opencode-go-api-key-source.ts
@@ -8,6 +8,7 @@ import { tableExists } from '../opencode-usage/schema-helpers'
import { isWslUncPath } from '../../shared/wsl-paths'
import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory'
import Database from '../sqlite/sync-database'
+import { getManagedDataAccountService } from '../managed-data-accounts/service'
import {
detectOpenCodeCredentialBackend,
type OpenCodeCredentialBackend
@@ -204,7 +205,12 @@ export async function resolveOpenCodeGoApiKey(input: {
if (override) {
return { status: 'found', key: override, tier: 'settings' }
}
- const environment = input.environment ?? process.env
+ const environment = input.environment ?? { ...process.env }
+ if (!input.environment) {
+ const accounts = getManagedDataAccountService()
+ accounts.restoreOriginalEnvironment(environment)
+ Object.assign(environment, accounts.launchEnvironment('opencode'))
+ }
const backend = input.backend ?? (await detectOpenCodeCredentialBackend(environment, input.cwd))
if (!backend) {
return { status: 'missing' }
diff --git a/src/main/rate-limits/opencode-go-managed-account-source.test.ts b/src/main/rate-limits/opencode-go-managed-account-source.test.ts
new file mode 100644
index 000000000000..c6ac24978d5b
--- /dev/null
+++ b/src/main/rate-limits/opencode-go-managed-account-source.test.ts
@@ -0,0 +1,241 @@
+import { randomUUID } from 'node:crypto'
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { getAppEnvironment, setAppEnvironment } from '../../shared/app-environment'
+import { getManagedDataAccountService } from '../managed-data-accounts/service'
+import { detectOpenCodeCredentialBackend } from '../opencode/opencode-credential-backend'
+import Database from '../sqlite/sync-database'
+import { resolveOpenCodeGoApiKey } from './opencode-go-api-key-source'
+
+vi.mock('../opencode/opencode-credential-backend', () => ({
+ detectOpenCodeCredentialBackend: vi.fn()
+}))
+
+let root: string
+let systemDataHome: string
+let metadataPath: string
+
+function writeCredentials(dataHome: string, name: string): void {
+ const directory = join(dataHome, 'opencode')
+ mkdirSync(directory, { recursive: true })
+ writeFileSync(
+ join(directory, 'auth.json'),
+ JSON.stringify({ 'opencode-go': { type: 'api', key: `${name}-auth-placeholder` } })
+ )
+ const database = new Database(join(directory, 'opencode.db'))
+ try {
+ database.exec(
+ 'CREATE TABLE session (id TEXT); ' +
+ 'CREATE TABLE credential (integration_id TEXT, value TEXT, active INTEGER, time_created INTEGER)'
+ )
+ database
+ .prepare('INSERT INTO credential VALUES (?, ?, 1, 1)')
+ .run('opencode-go', JSON.stringify({ type: 'key', key: `${name}-table-placeholder` }))
+ } finally {
+ database.close()
+ }
+}
+
+async function enrollSelectedAccount(): Promise {
+ const accounts = getManagedDataAccountService()
+ const source = join(root, 'source')
+ writeCredentials(source, 'selected')
+ const state = await accounts.add('opencode', source, 'Selected')
+ const selected = accounts.launchEnvironment('opencode')
+ writeFileSync(
+ join(selected.XDG_DATA_HOME, 'opencode', 'auth.json'),
+ readFileSync(join(source, 'opencode', 'auth.json'))
+ )
+ await accounts.select('opencode', null)
+ return state.accounts[0].id
+}
+
+beforeEach(() => {
+ root = mkdtempSync(join(tmpdir(), 'orca-go-managed-caller-'))
+ systemDataHome = join(root, 'system-data')
+ const original = getAppEnvironment()
+ setAppEnvironment({
+ ...original,
+ getPath: (name) => (name === 'userData' ? root : original.getPath(name))
+ })
+ metadataPath = join(root, 'managed-data-accounts', 'opencode', 'accounts.json')
+ for (const key of Object.keys(process.env)) {
+ if (key.startsWith('ORCA_DATA_ACCOUNT_')) {
+ vi.stubEnv(key, undefined)
+ }
+ }
+ vi.stubEnv('HOME', join(root, 'home'))
+ vi.stubEnv('XDG_DATA_HOME', systemDataHome)
+ vi.stubEnv('XDG_STATE_HOME', join(root, 'system-state'))
+ vi.stubEnv('XDG_CONFIG_HOME', join(root, 'config'))
+ vi.stubEnv('XDG_CACHE_HOME', join(root, 'cache'))
+ vi.stubEnv('OPENCODE_DB', 'opencode.db')
+ vi.stubEnv('OPENCODE_AUTH_CONTENT', undefined)
+ vi.stubEnv('OPENCODE_API_KEY', undefined)
+ vi.mocked(detectOpenCodeCredentialBackend).mockReset()
+ writeCredentials(systemDataHome, 'system')
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ getManagedDataAccountService().clearInlineAuthBaselines()
+ vi.unstubAllEnvs()
+ rmSync(root, { recursive: true, force: true })
+})
+
+describe('OpenCode Go execution-host managed account lookup', () => {
+ it.each(['v1', 'v2'] as const)(
+ 'uses System → selected → System credentials on %s',
+ async (backend) => {
+ vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue(backend)
+ const id = await enrollSelectedAccount()
+ const accounts = getManagedDataAccountService()
+ const inherited = { ...process.env }
+ const tier = backend === 'v1' ? 'opencode-auth-file' : 'opencode-credential-database'
+ const suffix = backend === 'v1' ? 'auth' : 'table'
+
+ await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({
+ status: 'found',
+ key: `system-${suffix}-placeholder`,
+ tier
+ })
+ await accounts.select('opencode', id)
+ await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({
+ status: 'found',
+ key: `selected-${suffix}-placeholder`,
+ tier
+ })
+ expect(detectOpenCodeCredentialBackend).toHaveBeenLastCalledWith(
+ expect.objectContaining(accounts.launchEnvironment('opencode')),
+ undefined
+ )
+ await accounts.select('opencode', null)
+ await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({
+ status: 'found',
+ key: `system-${suffix}-placeholder`,
+ tier
+ })
+ expect(process.env).toEqual(inherited)
+ }
+ )
+
+ it.each(['v1', 'v2'] as const)(
+ 'restores an opaque inline System baseline through the host service on %s',
+ async (backend) => {
+ vi.mocked(detectOpenCodeCredentialBackend).mockResolvedValue(backend)
+ const id = await enrollSelectedAccount()
+ const accounts = getManagedDataAccountService()
+ await accounts.select('opencode', id)
+ const selected = accounts.launchEnvironment('opencode')
+ const inline = JSON.stringify({
+ 'opencode-go': { type: 'api', key: 'system-inline-placeholder' }
+ })
+ const baseline: Record = {
+ XDG_DATA_HOME: systemDataHome,
+ XDG_STATE_HOME: join(root, 'system-state'),
+ OPENCODE_DB: 'opencode.db',
+ OPENCODE_AUTH_CONTENT: inline
+ }
+ accounts.captureOriginalEnvironment(baseline, selected)
+ const inheritedProfile: Record = {
+ ...baseline,
+ ...selected,
+ ORCA_DATA_ACCOUNT_PROVIDER: 'opencode',
+ ORCA_DATA_ACCOUNT_DATA_HOME: selected.XDG_DATA_HOME,
+ ORCA_DATA_ACCOUNT_STATE_HOME: selected.XDG_STATE_HOME
+ }
+ expect(inheritedProfile.ORCA_DATA_ACCOUNT_ORIGINAL_ENV).not.toContain(
+ 'system-inline-placeholder'
+ )
+ expect(JSON.parse(baseline.ORCA_DATA_ACCOUNT_ORIGINAL_ENV)).toMatchObject({
+ OPENCODE_AUTH_CONTENT: null,
+ inlineAuthReference: expect.any(String)
+ })
+ for (const [key, value] of Object.entries(inheritedProfile)) {
+ vi.stubEnv(key, value)
+ }
+ const inherited = { ...process.env }
+
+ await accounts.select('opencode', null)
+ const expected =
+ backend === 'v1'
+ ? { status: 'found', key: 'system-inline-placeholder', tier: 'opencode-auth-content' }
+ : {
+ status: 'found',
+ key: 'system-table-placeholder',
+ tier: 'opencode-credential-database'
+ }
+ await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual(expected)
+ await accounts.select('opencode', id)
+ await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({
+ status: 'found',
+ key: backend === 'v1' ? 'selected-auth-placeholder' : 'selected-table-placeholder',
+ tier: backend === 'v1' ? 'opencode-auth-file' : 'opencode-credential-database'
+ })
+ await accounts.select('opencode', null)
+ await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual(expected)
+ expect(process.env).toEqual(inherited)
+ }
+ )
+
+ it.each(['malformed', 'unknown', 'unreadable'] as const)(
+ 'rejects %s metadata before probing or falling back, while a manual key still wins',
+ async (failure) => {
+ mkdirSync(join(root, 'managed-data-accounts', 'opencode'), { recursive: true })
+ if (failure === 'unreadable') {
+ mkdirSync(metadataPath)
+ } else {
+ writeFileSync(
+ metadataPath,
+ failure === 'malformed'
+ ? '{invalid'
+ : JSON.stringify({
+ accounts: [],
+ activeAccountId: randomUUID()
+ })
+ )
+ }
+ const before = failure === 'unreadable' ? null : readFileSync(metadataPath, 'utf8')
+ const accounts = getManagedDataAccountService()
+ const restore = vi.spyOn(accounts, 'restoreOriginalEnvironment')
+ const selection = vi.spyOn(accounts, 'launchEnvironment')
+ const inherited = { ...process.env }
+
+ await expect(
+ resolveOpenCodeGoApiKey({ settingsOverride: ' manual-placeholder ' })
+ ).resolves.toEqual({
+ status: 'found',
+ key: 'manual-placeholder',
+ tier: 'settings'
+ })
+ expect(restore).not.toHaveBeenCalled()
+ expect(selection).not.toHaveBeenCalled()
+ expect(process.env).toEqual(inherited)
+ await expect(resolveOpenCodeGoApiKey({})).rejects.toThrow()
+ expect(detectOpenCodeCredentialBackend).not.toHaveBeenCalled()
+ if (before !== null) {
+ expect(readFileSync(metadataPath, 'utf8')).toBe(before)
+ }
+ expect(process.env).toEqual(inherited)
+ }
+ )
+
+ it('returns a manual key without resolving the host service or mutating the inherited environment', async () => {
+ const inherited = { ...process.env }
+ vi.spyOn(getAppEnvironment(), 'getPath').mockImplementation(() => {
+ throw new Error('Host metadata must not be resolved')
+ })
+ await expect(
+ resolveOpenCodeGoApiKey({ settingsOverride: 'manual-placeholder' })
+ ).resolves.toEqual({
+ status: 'found',
+ key: 'manual-placeholder',
+ tier: 'settings'
+ })
+ expect(detectOpenCodeCredentialBackend).not.toHaveBeenCalled()
+ expect(process.env).toEqual(inherited)
+ vi.restoreAllMocks()
+ })
+})
From fa9f6a0a57f202d0c11d8786db3807a941d52c69 Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 18:05:26 -0700
Subject: [PATCH 19/24] Fix managed account removal, credential audits, and
runtime bundling
---
.../electron-vite-output-contract.test.ts | 3 +
.../managed-data-account-runtime.test.ts | 106 ++++++++++++++++++
electron.vite.config.ts | 1 +
.../credential-capture.ts | 27 ++++-
.../managed-data-accounts/service.test.ts | 95 +++++++++++++++-
src/main/managed-data-accounts/service.ts | 56 +++++++--
6 files changed, 272 insertions(+), 16 deletions(-)
create mode 100644 config/scripts/managed-data-account-runtime.test.ts
diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts
index da6d45c8a230..fd45c42705fb 100644
--- a/config/scripts/electron-vite-output-contract.test.ts
+++ b/config/scripts/electron-vite-output-contract.test.ts
@@ -144,8 +144,11 @@ describe('Electron Vite output contract', () => {
expect(external('@xterm/addon-serialize', undefined, false)).toBe(false)
expect(external('tldts', undefined, false)).toBe(false)
expect(external('zod', undefined, false)).toBe(false)
+ expect(external('smol-toml', undefined, false)).toBe(false)
+ expect(external('smol-toml/package.json', undefined, false)).toBe(false)
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('tldts')
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod')
+ expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('smol-toml')
})
it('bundles validation dependencies used by the sandboxed preload', () => {
diff --git a/config/scripts/managed-data-account-runtime.test.ts b/config/scripts/managed-data-account-runtime.test.ts
new file mode 100644
index 000000000000..452629732ac6
--- /dev/null
+++ b/config/scripts/managed-data-account-runtime.test.ts
@@ -0,0 +1,106 @@
+import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { resolveConfig } from 'electron-vite'
+import { build } from 'vite'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { runProcess } from '../../src/shared/child-process/run-process'
+
+const projectDir = resolve(import.meta.dirname, '../..')
+const require = createRequire(import.meta.url)
+let outputDir: string
+
+beforeAll(async () => {
+ outputDir = mkdtempSync(join(tmpdir(), 'orca-account-runtime-'))
+ const resolved = await resolveConfig(
+ { configFile: join(projectDir, 'electron.vite.config.ts') },
+ 'build',
+ 'production'
+ )
+ const main = resolved.config?.main
+ if (!main?.build) {
+ throw new Error('Expected main-process build config')
+ }
+ await build({
+ ...main,
+ logLevel: 'silent',
+ build: {
+ ...main.build,
+ outDir: join(outputDir, 'bundle'),
+ sourcemap: false,
+ rollupOptions: {
+ ...main.build.rollupOptions,
+ input: join(projectDir, 'src/main/managed-data-accounts/credential-capture.ts'),
+ output: { format: 'cjs', entryFileNames: 'credential-capture.cjs' }
+ }
+ }
+ })
+ mkdirSync(join(outputDir, 'source', 'devin'), { recursive: true })
+ writeFileSync(
+ join(outputDir, 'source', 'devin', 'credentials.toml'),
+ 'windsurf_api_key = "offline-account-runtime-fixture"\n'
+ )
+})
+
+afterAll(() => {
+ if (outputDir) {
+ rmSync(outputDir, { recursive: true, force: true })
+ }
+})
+
+describe('managed account credentials in the production main bundle', () => {
+ it.each([
+ { name: 'Node', program: process.execPath },
+ { name: 'Electron', program: require('electron') }
+ ])(
+ 'captures Devin credentials under $name outside the dependency install',
+ async ({ name, program }) => {
+ const environment: Record = {
+ ...process.env,
+ ORCA_BACKGROUND_LAUNCH: '1',
+ ELECTRON_RUN_AS_NODE: '1'
+ }
+ for (const key of [
+ 'HOME',
+ 'XDG_CONFIG_HOME',
+ 'XDG_DATA_HOME',
+ 'XDG_STATE_HOME',
+ 'XDG_CACHE_HOME'
+ ]) {
+ const directory = join(outputDir, name, key)
+ mkdirSync(directory, { recursive: true })
+ environment[key] = directory
+ }
+ const script = `
+ const assert = require('node:assert/strict')
+ const { captureDataAccountCredentials } = require(process.argv[1])
+ captureDataAccountCredentials('devin', process.argv[2], process.argv[3])
+ .then((integrations) => {
+ assert.deepEqual(integrations, ['devin'])
+ console.log('Private credentials captured')
+ }).catch((error) => { console.error(error); process.exitCode = 1 })
+ `
+ const destination = join(outputDir, name, 'captured')
+ const result = await runProcess({
+ program,
+ args: [
+ '-e',
+ script,
+ join(outputDir, 'bundle', 'credential-capture.cjs'),
+ join(outputDir, 'source'),
+ destination
+ ],
+ cwd: outputDir,
+ env: environment,
+ timeoutMs: 20000
+ })
+ expect(result.code, result.stderr).toBe(0)
+ expect(result.timedOut).toBe(false)
+ expect(result.stdout.trim()).toBe('Private credentials captured')
+ expect(readFileSync(join(destination, 'devin', 'credentials.toml'), 'utf8')).toContain(
+ 'offline-account-runtime-fixture'
+ )
+ }
+ )
+})
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index d1b4e2d7b979..7e715a066169 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -16,6 +16,7 @@ const BUNDLED_MAIN_DEPENDENCIES = new Set([
'@xterm/headless',
'@xterm/addon-serialize',
'tldts',
+ 'smol-toml',
// Why: Windows NSIS deploys app.asar before external resources; bootstrap must
// not race the later resources/node_modules copy.
'zod'
diff --git a/src/main/managed-data-accounts/credential-capture.ts b/src/main/managed-data-accounts/credential-capture.ts
index 22de0bbe2e28..6a2036aceda3 100644
--- a/src/main/managed-data-accounts/credential-capture.ts
+++ b/src/main/managed-data-accounts/credential-capture.ts
@@ -3,6 +3,7 @@ import { join } from 'node:path'
import { z } from 'zod'
import { parse } from 'smol-toml'
import SyncDatabase from '../sqlite/sync-database'
+import { tableExists } from '../opencode-usage/schema-helpers'
import { writeSecureFile } from '../../shared/secure-file'
import type { ManagedDataAccountProvider } from '../../shared/managed-account-types'
@@ -58,14 +59,30 @@ export async function captureDataAccountCredentials(
})
try {
database.pragma('query_only = ON')
- const sessionTables = ['session', 'session_v2'].filter((name) =>
- database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)
- )
+ const sessionTables = ['session', 'session_v2'].filter((name) => tableExists(database, name))
if (sessionTables.length === 0) {
throw new Error('Unsupported OpenCode credential database.')
}
- // Both released session schemas must be empty before copying credentials.
- for (const table of sessionTables) {
+ // Deleted sessions can leave orphan content or durable events behind.
+ const conversationTables = [
+ ...sessionTables,
+ 'message',
+ 'part',
+ 'todo',
+ 'session_message',
+ 'session_pending',
+ 'session_inbox',
+ 'session_input',
+ 'session_context_epoch',
+ 'instruction_blob',
+ 'instruction_entry',
+ 'instruction_state',
+ 'event'
+ ]
+ for (const table of conversationTables) {
+ if (!tableExists(database, table)) {
+ continue
+ }
if (database.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get()) {
throw new Error(
'Use an isolated OpenCode login directory; importing conversation databases is not supported.'
diff --git a/src/main/managed-data-accounts/service.test.ts b/src/main/managed-data-accounts/service.test.ts
index d9ad4cd2c12e..8c247bbd5f1f 100644
--- a/src/main/managed-data-accounts/service.test.ts
+++ b/src/main/managed-data-accounts/service.test.ts
@@ -1,4 +1,4 @@
-import { beforeEach, afterEach, describe, expect, it } from 'vitest'
+import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import {
existsSync,
mkdirSync,
@@ -13,6 +13,7 @@ import {
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import SyncDatabase from '../sqlite/sync-database'
+import * as secureFile from '../../shared/secure-file'
import { ManagedDataAccountService } from './service'
let root: string
@@ -26,7 +27,10 @@ beforeEach(() => {
writeFileSync(join(source, 'devin', 'credentials.toml'), 'windsurf_api_key = "test-only-key"\n')
service = new ManagedDataAccountService(join(root, 'managed'))
})
-afterEach(() => rmSync(root, { recursive: true, force: true }))
+afterEach(() => {
+ vi.restoreAllMocks()
+ rmSync(root, { recursive: true, force: true })
+})
function openCodeSource(sessionTable = 'session'): void {
mkdirSync(join(source, 'opencode'), { recursive: true })
@@ -42,6 +46,51 @@ function openCodeSource(sessionTable = 'session'): void {
}
describe('managed data accounts', () => {
+ it.each(['before write', 'after write', 'unrestricted'])(
+ 'preserves credentials and original metadata when removal persistence fails %s',
+ async (failure) => {
+ const before = await service.add('devin', source, 'Work')
+ const environment = service.launchEnvironment('devin')
+ const credentialsPath = join(environment.XDG_DATA_HOME, 'devin', 'credentials.toml')
+ const credentials = readFileSync(credentialsPath)
+ const metadataPath = join(root, 'managed', 'devin', 'accounts.json')
+ const metadata = readFileSync(metadataPath)
+ const changed = vi.fn()
+ service.onChanged(changed)
+ const write = secureFile.writeSecureFile
+ const failingWrite = vi.spyOn(secureFile, 'writeSecureFile').mockImplementation((...args) => {
+ if (args[0] !== metadataPath) {
+ return write(...args)
+ }
+ if (failure === 'before write') {
+ throw new Error('metadata write failed')
+ }
+ write(...args)
+ if (failure === 'unrestricted') {
+ return false
+ }
+ throw new Error('metadata write failed')
+ })
+
+ await expect(service.remove('devin', before.accounts[0].id)).rejects.toThrow(
+ failure === 'unrestricted' ? 'metadata permissions' : 'metadata write failed'
+ )
+ expect(readFileSync(credentialsPath)).toEqual(credentials)
+ expect(readFileSync(metadataPath)).toEqual(metadata)
+ expect(service.list('devin')).toEqual(before)
+ expect(service.launchEnvironment('devin')).toEqual(environment)
+ expect(changed).not.toHaveBeenCalled()
+ expect(readdirSync(join(root, 'managed', 'devin')).sort()).toEqual(
+ [before.accounts[0].id, 'accounts.json'].sort()
+ )
+
+ failingWrite.mockRestore()
+ await service.remove('devin', before.accounts[0].id)
+ expect(changed).toHaveBeenCalledTimes(1)
+ expect(service.list('devin')).toEqual({ accounts: [], activeAccountId: null })
+ }
+ )
+
it('retains selected account metadata after locked cleanup and permits retry', async () => {
let locked = true
service = new ManagedDataAccountService(join(root, 'managed'), (directory) => {
@@ -51,12 +100,23 @@ describe('managed data accounts', () => {
rmSync(directory, { recursive: true, force: true })
})
const before = await service.add('devin', source, 'Work')
+ const environment = service.launchEnvironment('devin')
+ const metadataPath = join(root, 'managed', 'devin', 'accounts.json')
+ const metadata = readFileSync(metadataPath)
+ const changed = vi.fn()
+ service.onChanged(changed)
await expect(service.remove('devin', before.accounts[0].id)).rejects.toThrow('file locked')
expect(service.list('devin')).toEqual(before)
- expect(service.launchEnvironment('devin').XDG_DATA_HOME).toBeTruthy()
+ expect(readFileSync(metadataPath)).toEqual(metadata)
+ expect(service.launchEnvironment('devin')).toEqual(environment)
+ expect(
+ readFileSync(join(environment.XDG_DATA_HOME, 'devin', 'credentials.toml'), 'utf8')
+ ).toContain('test-only-key')
+ expect(changed).not.toHaveBeenCalled()
locked = false
await service.remove('devin', before.accounts[0].id)
expect(service.list('devin')).toEqual({ accounts: [], activeAccountId: null })
+ expect(changed).toHaveBeenCalledTimes(1)
})
it('registers private Devin credentials, exposes summaries, and removes only its profile', async () => {
@@ -117,6 +177,35 @@ describe('managed data accounts', () => {
expect(readdirSync(join(root, 'managed', 'opencode'))).toEqual([])
})
+ it.each([
+ 'message',
+ 'part',
+ 'todo',
+ 'session_message',
+ 'session_pending',
+ 'session_inbox',
+ 'session_input',
+ 'session_context_epoch',
+ 'instruction_blob',
+ 'instruction_entry',
+ 'instruction_state',
+ 'event'
+ ])('rejects synthetic orphan %s rows even with empty session containers', async (table) => {
+ openCodeSource('session_v2')
+ const databasePath = join(source, 'opencode', 'opencode.db')
+ const db = new SyncDatabase(databasePath)
+ // Synthetic orphans exercise damaged/FK-off files, not normal CLI writes.
+ db.exec(`CREATE TABLE ${table} (data TEXT)`)
+ db.prepare(`INSERT INTO ${table} VALUES (?)`).run('private-conversation-content')
+ db.close()
+ const original = readFileSync(databasePath)
+
+ await expect(service.add('opencode', source, 'Work')).rejects.toThrow('conversation databases')
+ expect(service.list('opencode')).toEqual({ accounts: [], activeAccountId: null })
+ expect(readdirSync(join(root, 'managed', 'opencode'))).toEqual([])
+ expect(readFileSync(databasePath)).toEqual(original)
+ })
+
it('serializes overlapping enrollment so neither account is lost', async () => {
await Promise.all([service.add('devin', source, 'One'), service.add('devin', source, 'Two')])
expect(service.list('devin').accounts.map((account) => account.label)).toEqual(['One', 'Two'])
diff --git a/src/main/managed-data-accounts/service.ts b/src/main/managed-data-accounts/service.ts
index 5393b55ea0d8..4b51caa27420 100644
--- a/src/main/managed-data-accounts/service.ts
+++ b/src/main/managed-data-accounts/service.ts
@@ -1,5 +1,13 @@
import { randomUUID } from 'node:crypto'
-import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs'
+import {
+ existsSync,
+ lstatSync,
+ mkdirSync,
+ readFileSync,
+ realpathSync,
+ renameSync,
+ rmSync
+} from 'node:fs'
import { join, resolve, sep } from 'node:path'
import { z } from 'zod'
import { getAppEnvironment } from '../../shared/app-environment'
@@ -110,11 +118,31 @@ export class ManagedDataAccountService {
if (existsSync(directory)) {
this.assertOwned(directory)
}
- this.removeDirectory(directory)
- return this.persist(provider, {
- accounts: state.accounts.filter((account) => account.id !== accountId),
- activeAccountId: state.activeAccountId === accountId ? null : state.activeAccountId
- })
+ const metadataPath = join(this.root, provider, 'accounts.json')
+ const rollbackPath = `${metadataPath}.${randomUUID()}.rollback`
+ if (!writeSecureFile(rollbackPath, readFileSync(metadataPath, 'utf8'), { durable: true })) {
+ rmSync(rollbackPath, { force: true })
+ throw new Error('Could not restrict account metadata backup permissions.')
+ }
+ let next: ManagedDataAccountsState
+ try {
+ // Keep the original metadata until persistence and cleanup both succeed.
+ next = this.writeState(provider, {
+ accounts: state.accounts.filter((account) => account.id !== accountId),
+ activeAccountId: state.activeAccountId === accountId ? null : state.activeAccountId
+ })
+ this.removeDirectory(directory)
+ } catch (error) {
+ renameSync(rollbackPath, metadataPath)
+ throw error
+ }
+ try {
+ rmSync(rollbackPath, { force: true })
+ } catch {
+ console.warn('[managed-data-accounts] Could not remove account metadata backup.')
+ }
+ this.notifyChanged()
+ return next
})
}
@@ -217,19 +245,31 @@ export class ManagedDataAccountService {
private persist(
provider: ManagedDataAccountProvider,
state: ManagedDataAccountsState
+ ): ManagedDataAccountsState {
+ const checked = this.writeState(provider, state)
+ this.notifyChanged()
+ return checked
+ }
+
+ private writeState(
+ provider: ManagedDataAccountProvider,
+ state: ManagedDataAccountsState
): ManagedDataAccountsState {
const checked = stateSchema.parse(state)
const path = join(this.root, provider, 'accounts.json')
if (existsSync(path)) {
this.assertOwned(path)
}
- if (!writeSecureFile(path, JSON.stringify(checked))) {
+ if (!writeSecureFile(path, JSON.stringify(checked), { durable: true })) {
throw new Error('Could not restrict account metadata permissions.')
}
+ return checked
+ }
+
+ private notifyChanged(): void {
for (const listener of this.listeners) {
listener()
}
- return checked
}
private assertOwned(path: string): void {
From 3c62b4a0ba13120c215cbb0e8c86e6891f85f63f Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 21:03:01 -0700
Subject: [PATCH 20/24] Quarantine removed accounts and audit captured
credential snapshots
---
.../credential-capture.ts | 126 ++++----
.../managed-data-accounts/profile-removal.ts | 138 +++++++++
.../managed-data-accounts/service.test.ts | 278 +++++++++++++++++-
src/main/managed-data-accounts/service.ts | 79 ++---
4 files changed, 515 insertions(+), 106 deletions(-)
create mode 100644 src/main/managed-data-accounts/profile-removal.ts
diff --git a/src/main/managed-data-accounts/credential-capture.ts b/src/main/managed-data-accounts/credential-capture.ts
index 6a2036aceda3..dd077d4aa05e 100644
--- a/src/main/managed-data-accounts/credential-capture.ts
+++ b/src/main/managed-data-accounts/credential-capture.ts
@@ -1,4 +1,4 @@
-import { lstatSync, readFileSync } from 'node:fs'
+import { lstatSync, readFileSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { z } from 'zod'
import { parse } from 'smol-toml'
@@ -26,6 +26,59 @@ function requireRegularFile(path: string): void {
}
}
+function auditOpenCodeCredentials(database: SyncDatabase): string[] {
+ database.pragma('query_only = ON')
+ const sessionTables = ['session', 'session_v2'].filter((name) => tableExists(database, name))
+ if (sessionTables.length === 0) {
+ throw new Error('Unsupported OpenCode credential database.')
+ }
+ // Deleted sessions can leave orphan content or durable events behind.
+ const conversationTables = [
+ ...sessionTables,
+ 'message',
+ 'part',
+ 'todo',
+ 'session_message',
+ 'session_pending',
+ 'session_inbox',
+ 'session_input',
+ 'session_context_epoch',
+ 'instruction_blob',
+ 'instruction_entry',
+ 'instruction_state',
+ 'event'
+ ]
+ for (const table of conversationTables) {
+ if (!tableExists(database, table)) {
+ continue
+ }
+ if (database.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get()) {
+ throw new Error(
+ 'Use an isolated OpenCode login directory; importing conversation databases is not supported.'
+ )
+ }
+ }
+ const rows = database.prepare('SELECT integration_id, value FROM credential LIMIT 65').all()
+ if (rows.length === 0 || rows.length > 64) {
+ throw new Error('OpenCode login did not save a supported credential.')
+ }
+ return rows.map((row) => {
+ if (typeof row.integration_id !== 'string' || typeof row.value !== 'string') {
+ throw new Error('Unsupported OpenCode credential database.')
+ }
+ let value: unknown
+ try {
+ value = JSON.parse(row.value)
+ } catch {
+ throw new Error('Unsupported OpenCode credential format.')
+ }
+ if (!credential.safeParse(value).success) {
+ throw new Error('Unsupported OpenCode credential format.')
+ }
+ return row.integration_id
+ })
+}
+
export async function captureDataAccountCredentials(
provider: ManagedDataAccountProvider,
sourceDataHome: string,
@@ -57,63 +110,30 @@ export async function captureDataAccountCredentials(
fileMustExist: true,
timeout: 1500
})
+ const destination = join(destinationDataHome, 'opencode', 'opencode.db')
+ let snapshotCreated = false
try {
- database.pragma('query_only = ON')
- const sessionTables = ['session', 'session_v2'].filter((name) => tableExists(database, name))
- if (sessionTables.length === 0) {
- throw new Error('Unsupported OpenCode credential database.')
- }
- // Deleted sessions can leave orphan content or durable events behind.
- const conversationTables = [
- ...sessionTables,
- 'message',
- 'part',
- 'todo',
- 'session_message',
- 'session_pending',
- 'session_inbox',
- 'session_input',
- 'session_context_epoch',
- 'instruction_blob',
- 'instruction_entry',
- 'instruction_state',
- 'event'
- ]
- for (const table of conversationTables) {
- if (!tableExists(database, table)) {
- continue
- }
- if (database.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get()) {
- throw new Error(
- 'Use an isolated OpenCode login directory; importing conversation databases is not supported.'
- )
- }
- }
- const rows = database.prepare('SELECT integration_id, value FROM credential LIMIT 65').all()
- if (rows.length === 0 || rows.length > 64) {
- throw new Error('OpenCode login did not save a supported credential.')
- }
- const integrations = rows.map((row) => {
- if (typeof row.integration_id !== 'string' || typeof row.value !== 'string') {
- throw new Error('Unsupported OpenCode credential database.')
- }
- let value: unknown
- try {
- value = JSON.parse(row.value)
- } catch {
- throw new Error('Unsupported OpenCode credential format.')
- }
- if (!credential.safeParse(value).success) {
- throw new Error('Unsupported OpenCode credential format.')
- }
- return row.integration_id
- })
- const destination = join(destinationDataHome, 'opencode', 'opencode.db')
+ auditOpenCodeCredentials(database)
+ snapshotCreated = true
if (!writeSecureFile(destination, '')) {
throw new Error('Could not restrict OpenCode credential file permissions.')
}
await database.backup(destination)
- return integrations
+ // The source can change while SQLite copies; only the completed private snapshot is publishable.
+ requireRegularFile(destination)
+ const snapshot = new SyncDatabase(destination, { readonly: true, fileMustExist: true })
+ try {
+ return auditOpenCodeCredentials(snapshot)
+ } finally {
+ snapshot.close()
+ }
+ } catch (error) {
+ if (snapshotCreated) {
+ for (const path of [destination, `${destination}-wal`, `${destination}-shm`]) {
+ rmSync(path, { force: true })
+ }
+ }
+ throw error
} finally {
database.close()
}
diff --git a/src/main/managed-data-accounts/profile-removal.ts b/src/main/managed-data-accounts/profile-removal.ts
new file mode 100644
index 000000000000..25f678317e80
--- /dev/null
+++ b/src/main/managed-data-accounts/profile-removal.ts
@@ -0,0 +1,138 @@
+import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs'
+import { readdir } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import { z } from 'zod'
+import { removeHostTree } from '../host-tree-removal'
+import type {
+ ManagedDataAccountProvider,
+ ManagedDataAccountsState
+} from '../../shared/managed-account-types'
+import { writeSecureFile } from '../../shared/secure-file'
+
+export class ManagedDataAccountProfileRemoval {
+ constructor(
+ private readonly root: string,
+ private readonly assertOwned: (path: string) => void,
+ private readonly removeDirectory: (directory: string) => void | Promise = removeHostTree
+ ) {}
+
+ async remove(
+ provider: ManagedDataAccountProvider,
+ accountId: string,
+ state: ManagedDataAccountsState,
+ publish: (state: ManagedDataAccountsState) => ManagedDataAccountsState,
+ changed: () => void
+ ): Promise {
+ if (!z.uuid().safeParse(accountId).success) {
+ throw new Error('Managed account not found.')
+ }
+ const directory = join(this.root, provider, accountId)
+ const pendingDirectory = join(this.root, provider, '.pending-delete', accountId)
+ const metadataPath = join(this.root, provider, 'accounts.json')
+ const rollbackPath = `${metadataPath}.${accountId}.rollback`
+ if (!state.accounts.some((account) => account.id === accountId)) {
+ if (existsSync(rollbackPath) && existsSync(directory)) {
+ this.assertOwned(rollbackPath)
+ this.quarantine(directory, pendingDirectory)
+ changed()
+ } else if (!existsSync(pendingDirectory)) {
+ throw new Error('Managed account not found.')
+ }
+ this.discardBackup(rollbackPath)
+ await this.cleanup(pendingDirectory)
+ return state
+ }
+ if (existsSync(rollbackPath)) {
+ this.assertOwned(rollbackPath)
+ }
+ if (!writeSecureFile(rollbackPath, readFileSync(metadataPath, 'utf8'), { durable: true })) {
+ rmSync(rollbackPath, { force: true })
+ throw new Error('Could not restrict account metadata backup permissions.')
+ }
+ let next: ManagedDataAccountsState
+ try {
+ next = publish({
+ accounts: state.accounts.filter((account) => account.id !== accountId),
+ activeAccountId: state.activeAccountId === accountId ? null : state.activeAccountId
+ })
+ this.quarantine(directory, pendingDirectory)
+ } catch (error) {
+ try {
+ renameSync(rollbackPath, metadataPath)
+ } catch (rollbackError) {
+ throw new AggregateError(
+ [error, rollbackError],
+ 'Account removal failed and its private metadata backup could not be restored; retry removal to recover.',
+ { cause: error }
+ )
+ }
+ throw error
+ }
+ this.discardBackup(rollbackPath)
+ changed()
+ // Cleanup can partially delete a tree, so it must never roll back a committed removal.
+ await this.cleanup(pendingDirectory)
+ return next
+ }
+
+ private quarantine(directory: string, pendingDirectory: string): void {
+ if (!existsSync(directory)) {
+ return
+ }
+ this.assertOwned(directory)
+ if (existsSync(pendingDirectory)) {
+ throw new Error('Account already has a pending removal directory.')
+ }
+ const pendingRoot = dirname(pendingDirectory)
+ mkdirSync(pendingRoot, { recursive: true, mode: 0o700 })
+ this.assertOwned(pendingRoot)
+ renameSync(directory, pendingDirectory)
+ }
+
+ private discardBackup(rollbackPath: string): void {
+ try {
+ rmSync(rollbackPath, { force: true })
+ } catch {
+ console.warn('[managed-data-accounts] Could not remove account metadata backup.')
+ }
+ }
+
+ private async cleanup(directory: string): Promise {
+ if (!existsSync(directory)) {
+ return
+ }
+ try {
+ this.assertOwned(dirname(directory))
+ this.assertOwned(directory)
+ await this.removeDirectory(directory)
+ } catch {
+ console.warn(
+ '[managed-data-accounts] Account removed; private directory cleanup is deferred.'
+ )
+ }
+ }
+
+ async retry(provider: ManagedDataAccountProvider, registered: Set): Promise {
+ const pendingRoot = join(this.root, provider, '.pending-delete')
+ if (!existsSync(pendingRoot)) {
+ return
+ }
+ try {
+ this.assertOwned(pendingRoot)
+ const entries = await readdir(pendingRoot, { withFileTypes: true })
+ for (const entry of entries) {
+ if (
+ !entry.isDirectory() ||
+ !z.uuid().safeParse(entry.name).success ||
+ registered.has(entry.name)
+ ) {
+ continue
+ }
+ await this.cleanup(join(pendingRoot, entry.name))
+ this.discardBackup(join(this.root, provider, `accounts.json.${entry.name}.rollback`))
+ }
+ } catch {
+ console.warn('[managed-data-accounts] Could not retry private account directory cleanup.')
+ }
+ }
+}
diff --git a/src/main/managed-data-accounts/service.test.ts b/src/main/managed-data-accounts/service.test.ts
index 8c247bbd5f1f..5ab7f12d04c9 100644
--- a/src/main/managed-data-accounts/service.test.ts
+++ b/src/main/managed-data-accounts/service.test.ts
@@ -1,4 +1,6 @@
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
+import { randomUUID } from 'node:crypto'
+import * as fileSystem from 'node:fs'
import {
existsSync,
mkdirSync,
@@ -16,6 +18,10 @@ import SyncDatabase from '../sqlite/sync-database'
import * as secureFile from '../../shared/secure-file'
import { ManagedDataAccountService } from './service'
+vi.mock('node:fs', async (importOriginal) => ({
+ ...(await importOriginal())
+}))
+
let root: string
let source: string
let service: ManagedDataAccountService
@@ -91,16 +97,18 @@ describe('managed data accounts', () => {
}
)
- it('retains selected account metadata after locked cleanup and permits retry', async () => {
+ it('retains selected account metadata when the atomic rename is locked and permits retry', async () => {
let locked = true
- service = new ManagedDataAccountService(join(root, 'managed'), (directory) => {
- if (locked) {
+ const before = await service.add('devin', source, 'Work')
+ const environment = service.launchEnvironment('devin')
+ const directory = join(root, 'managed', 'devin', before.accounts[0].id)
+ const rename = fileSystem.renameSync
+ vi.spyOn(fileSystem, 'renameSync').mockImplementation((from, to) => {
+ if (locked && from === directory) {
throw new Error('file locked')
}
- rmSync(directory, { recursive: true, force: true })
+ return rename(from, to)
})
- const before = await service.add('devin', source, 'Work')
- const environment = service.launchEnvironment('devin')
const metadataPath = join(root, 'managed', 'devin', 'accounts.json')
const metadata = readFileSync(metadataPath)
const changed = vi.fn()
@@ -119,6 +127,141 @@ describe('managed data accounts', () => {
expect(changed).toHaveBeenCalledTimes(1)
})
+ it('preserves both transaction errors and a private recovery backup when metadata rollback fails', async () => {
+ const before = await service.add('devin', source, 'Work')
+ const id = before.accounts[0].id
+ const directory = join(root, 'managed', 'devin', id)
+ const credentialsPath = join(directory, 'data', 'devin', 'credentials.toml')
+ const credentials = readFileSync(credentialsPath)
+ const metadataPath = join(root, 'managed', 'devin', 'accounts.json')
+ const metadata = readFileSync(metadataPath)
+ const originalError = Object.assign(new Error('injected quarantine rename failure'), {
+ code: 'EPERM'
+ })
+ const rollbackError = Object.assign(new Error('injected metadata rollback failure'), {
+ code: 'EACCES'
+ })
+ const rename = fileSystem.renameSync
+ const failingRename = vi.spyOn(fileSystem, 'renameSync').mockImplementation((from, to) => {
+ if (from === directory) {
+ throw originalError
+ }
+ if (typeof from === 'string' && from.endsWith('.rollback')) {
+ throw rollbackError
+ }
+ return rename(from, to)
+ })
+ const changed = vi.fn()
+ service.onChanged(changed)
+ let failure: unknown
+ try {
+ await service.remove('devin', id)
+ } catch (error) {
+ failure = error
+ }
+ expect(failure).toBeInstanceOf(AggregateError)
+ if (!(failure instanceof AggregateError)) {
+ throw new Error('Expected both removal and rollback failures.')
+ }
+ expect(failure.errors).toEqual([originalError, rollbackError])
+ expect(failure.cause).toBe(originalError)
+ expect(readFileSync(`${metadataPath}.${id}.rollback`)).toEqual(metadata)
+ expect(readFileSync(credentialsPath)).toEqual(credentials)
+ expect(service.list('devin')).toEqual({ accounts: [], activeAccountId: null })
+ expect(service.launchEnvironment('devin')).toEqual({})
+ expect(changed).not.toHaveBeenCalled()
+
+ failingRename.mockRestore()
+ await expect(service.remove('devin', id)).resolves.toEqual({
+ accounts: [],
+ activeAccountId: null
+ })
+ expect(existsSync(directory)).toBe(false)
+ expect(existsSync(`${metadataPath}.${id}.rollback`)).toBe(false)
+ expect(changed).toHaveBeenCalledTimes(1)
+ })
+
+ it('commits logical removal without reselecting a partially deleted profile and retries cleanup', async () => {
+ let locked = true
+ let cleanupDirectory: string | undefined
+ service = new ManagedDataAccountService(join(root, 'managed'), (directory) => {
+ cleanupDirectory = directory
+ if (locked) {
+ rmSync(join(directory, 'data'), { recursive: true, force: true })
+ throw Object.assign(new Error('state file locked after credential deletion'), {
+ code: 'EPERM'
+ })
+ }
+ rmSync(directory, { recursive: true, force: true })
+ })
+ const before = await service.add('devin', source, 'Work')
+ const id = before.accounts[0].id
+ const environment = service.launchEnvironment('devin')
+ mkdirSync(environment.XDG_STATE_HOME, { recursive: true })
+ writeFileSync(join(environment.XDG_STATE_HOME, 'locked-file'), 'remaining private state')
+ const changed = vi.fn()
+ service.onChanged(changed)
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+ await expect(service.remove('devin', id)).resolves.toEqual({
+ accounts: [],
+ activeAccountId: null
+ })
+ expect(service.list('devin')).toEqual({ accounts: [], activeAccountId: null })
+ expect(service.launchEnvironment('devin')).toEqual({})
+ expect(service.transcriptEnvironments('devin')).toEqual([])
+ expect(cleanupDirectory).toBeDefined()
+ expect(cleanupDirectory).not.toBe(join(root, 'managed', 'devin', id))
+ expect(existsSync(join(environment.XDG_DATA_HOME, 'devin', 'credentials.toml'))).toBe(false)
+ expect(changed).toHaveBeenCalledTimes(1)
+
+ locked = false
+ await expect(service.remove('devin', id)).resolves.toEqual({
+ accounts: [],
+ activeAccountId: null
+ })
+ expect(cleanupDirectory && existsSync(cleanupDirectory)).toBe(false)
+ expect(changed).toHaveBeenCalledTimes(1)
+ expect(readFileSync(join(source, 'devin', 'credentials.toml'), 'utf8')).toContain(
+ 'test-only-key'
+ )
+ })
+
+ it('retries quarantined cleanup on restart without reviving a removed account', async () => {
+ service = new ManagedDataAccountService(join(root, 'managed'), () => {
+ throw new Error('injected cleanup lock')
+ })
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const before = await service.add('devin', source, 'Work')
+ const id = before.accounts[0].id
+ await service.remove('devin', id)
+ const pendingDirectory = join(root, 'managed', 'devin', '.pending-delete', id)
+ expect(existsSync(pendingDirectory)).toBe(true)
+
+ const restarted = new ManagedDataAccountService(join(root, 'managed'))
+ expect(restarted.list('devin')).toEqual({ accounts: [], activeAccountId: null })
+ expect(restarted.launchEnvironment('devin')).toEqual({})
+ await vi.waitFor(() => expect(existsSync(pendingDirectory)).toBe(false))
+ expect(restarted.list('devin')).toEqual({ accounts: [], activeAccountId: null })
+ })
+
+ it('retries every eligible quarantine while preserving registered and unrecognized directories', async () => {
+ const before = await service.add('devin', source, 'Work')
+ const pendingRoot = join(root, 'managed', 'devin', '.pending-delete')
+ const pendingIds = Array.from({ length: 65 }, () => randomUUID())
+ for (const id of [...pendingIds, before.accounts[0].id, 'unrecognized']) {
+ mkdirSync(join(pendingRoot, id), { recursive: true })
+ }
+ const restarted = new ManagedDataAccountService(join(root, 'managed'))
+ await vi.waitFor(() =>
+ expect(readdirSync(pendingRoot).sort()).toEqual(
+ [before.accounts[0].id, 'unrecognized'].sort()
+ )
+ )
+ expect(restarted.list('devin')).toEqual(before)
+ expect(restarted.launchEnvironment('devin')).toEqual(service.launchEnvironment('devin'))
+ })
+
it('registers private Devin credentials, exposes summaries, and removes only its profile', async () => {
const state = await service.add('devin', source, 'Work')
const id = state.accounts[0].id
@@ -167,6 +310,129 @@ describe('managed data accounts', () => {
}
})
+ it.each(['session_v2', 'session_message'])(
+ 'rejects %s rows committed after source validation before the real SQLite backup',
+ async (table) => {
+ openCodeSource('session_v2')
+ const writer = new SyncDatabase(join(source, 'opencode', 'opencode.db'))
+ writer.pragma('journal_mode = WAL')
+ writer.exec('CREATE TABLE session_message (data TEXT)')
+ const backup = SyncDatabase.prototype.backup
+ const backupSpy = vi.spyOn(SyncDatabase.prototype, 'backup').mockImplementation(function (
+ this: SyncDatabase,
+ destination,
+ options
+ ) {
+ writer.prepare(`INSERT INTO ${table} VALUES (?)`).run('injected conversation after audit')
+ return backup.call(this, destination, options)
+ })
+ try {
+ await expect(service.add('opencode', source, 'Work')).rejects.toThrow(
+ 'conversation databases'
+ )
+ expect(backupSpy).toHaveBeenCalledTimes(1)
+ expect(service.list('opencode')).toEqual({ accounts: [], activeAccountId: null })
+ expect(readdirSync(join(root, 'managed', 'opencode'))).toEqual([])
+ expect(writer.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get()?.count).toBe(1)
+ } finally {
+ writer.close()
+ }
+ }
+ )
+
+ it('rejects conversation committed by a second WAL writer during asynchronous backup', async () => {
+ openCodeSource('session_v2')
+ const writer = new SyncDatabase(join(source, 'opencode', 'opencode.db'))
+ writer.pragma('journal_mode = WAL')
+ writer.exec('CREATE TABLE padding (data BLOB); INSERT INTO padding VALUES (zeroblob(65536))')
+ let mutated = false
+ const backup = SyncDatabase.prototype.backup
+ vi.spyOn(SyncDatabase.prototype, 'backup').mockImplementation(function (
+ this: SyncDatabase,
+ destination,
+ options
+ ) {
+ return backup.call(this, destination, {
+ ...options,
+ rate: 1,
+ progress: ({ remainingPages }) => {
+ if (!mutated && remainingPages > 0) {
+ writer.prepare('INSERT INTO session_v2 VALUES (?)').run('injected concurrent session')
+ mutated = true
+ }
+ }
+ })
+ })
+ try {
+ await expect(service.add('opencode', source, 'Work')).rejects.toThrow(
+ 'conversation databases'
+ )
+ expect(mutated).toBe(true)
+ expect(service.list('opencode')).toEqual({ accounts: [], activeAccountId: null })
+ expect(readdirSync(join(root, 'managed', 'opencode'))).toEqual([])
+ } finally {
+ writer.close()
+ }
+ })
+
+ it('publishes integrations from the completed credential snapshot', async () => {
+ openCodeSource('session_v2')
+ const writer = new SyncDatabase(join(source, 'opencode', 'opencode.db'))
+ writer.pragma('journal_mode = WAL')
+ const backup = SyncDatabase.prototype.backup
+ vi.spyOn(SyncDatabase.prototype, 'backup').mockImplementation(function (
+ this: SyncDatabase,
+ destination,
+ options
+ ) {
+ writer
+ .prepare('INSERT INTO credential VALUES (?, ?)')
+ .run('google', JSON.stringify({ type: 'key', key: 'injected-new-test-credential' }))
+ return backup.call(this, destination, options)
+ })
+ try {
+ const state = await service.add('opencode', source, 'Work')
+ expect(state.accounts[0].integrations).toEqual(['opencode-go', 'google'])
+ } finally {
+ writer.close()
+ }
+ })
+
+ it.each(['empty', 'invalid'])(
+ 'rejects %s credentials committed after source validation and preserves the selected account',
+ async (change) => {
+ openCodeSource('session_v2')
+ const before = await service.add('opencode', source, 'Existing')
+ const writer = new SyncDatabase(join(source, 'opencode', 'opencode.db'))
+ writer.pragma('journal_mode = WAL')
+ const backup = SyncDatabase.prototype.backup
+ vi.spyOn(SyncDatabase.prototype, 'backup').mockImplementation(function (
+ this: SyncDatabase,
+ destination,
+ options
+ ) {
+ writer.exec('DELETE FROM credential')
+ if (change === 'invalid') {
+ writer
+ .prepare('INSERT INTO credential VALUES (?, ?)')
+ .run('google', '{"type":"key","key":""}')
+ }
+ return backup.call(this, destination, options)
+ })
+ try {
+ await expect(service.add('opencode', source, 'Rejected')).rejects.toThrow(
+ change === 'empty' ? 'supported credential' : 'credential format'
+ )
+ expect(service.list('opencode')).toEqual(before)
+ expect(readdirSync(join(root, 'managed', 'opencode')).sort()).toEqual(
+ [before.accounts[0].id, 'accounts.json'].sort()
+ )
+ } finally {
+ writer.close()
+ }
+ }
+ )
+
it('rejects importing personal conversation databases and rolls back the directory', async () => {
openCodeSource()
const db = new SyncDatabase(join(source, 'opencode', 'opencode.db'))
diff --git a/src/main/managed-data-accounts/service.ts b/src/main/managed-data-accounts/service.ts
index 4b51caa27420..180565d23d4d 100644
--- a/src/main/managed-data-accounts/service.ts
+++ b/src/main/managed-data-accounts/service.ts
@@ -1,13 +1,5 @@
import { randomUUID } from 'node:crypto'
-import {
- existsSync,
- lstatSync,
- mkdirSync,
- readFileSync,
- realpathSync,
- renameSync,
- rmSync
-} from 'node:fs'
+import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs'
import { join, resolve, sep } from 'node:path'
import { z } from 'zod'
import { getAppEnvironment } from '../../shared/app-environment'
@@ -17,6 +9,7 @@ import type {
ManagedDataAccountsState
} from '../../shared/managed-account-types'
import { captureDataAccountCredentials } from './credential-capture'
+import { ManagedDataAccountProfileRemoval } from './profile-removal'
import {
captureManagedDataAccountOriginalEnvironment,
restoreManagedDataAccountEnvironment
@@ -45,12 +38,30 @@ export class ManagedDataAccountService {
private pending: Promise = Promise.resolve()
private readonly listeners = new Set<() => void>()
private readonly inlineAuthBaselines = new Map()
+ private readonly profileRemoval: ManagedDataAccountProfileRemoval
constructor(
private readonly root: string,
- private readonly removeDirectory: (directory: string) => void = (directory) =>
- rmSync(directory, { recursive: true, force: true })
- ) {}
+ removeDirectory?: (directory: string) => void | Promise
+ ) {
+ this.profileRemoval = new ManagedDataAccountProfileRemoval(
+ root,
+ (path) => this.assertOwned(path),
+ removeDirectory
+ )
+ if (existsSync(root)) {
+ for (const provider of ['opencode', 'devin'] as const) {
+ void this.mutate(async () => {
+ const registered = new Set(this.list(provider).accounts.map((account) => account.id))
+ await this.profileRemoval.retry(provider, registered)
+ }).catch(() => {
+ console.warn(
+ '[managed-data-accounts] Could not read accounts for private directory cleanup.'
+ )
+ })
+ }
+ }
+ }
list(provider: ManagedDataAccountProvider): ManagedDataAccountsState {
const path = join(this.root, provider, 'accounts.json')
@@ -109,41 +120,15 @@ export class ManagedDataAccountService {
provider: ManagedDataAccountProvider,
accountId: string
): Promise {
- return this.mutate(async () => {
- const state = this.list(provider)
- if (!state.accounts.some((account) => account.id === accountId)) {
- throw new Error('Managed account not found.')
- }
- const directory = join(this.root, provider, accountId)
- if (existsSync(directory)) {
- this.assertOwned(directory)
- }
- const metadataPath = join(this.root, provider, 'accounts.json')
- const rollbackPath = `${metadataPath}.${randomUUID()}.rollback`
- if (!writeSecureFile(rollbackPath, readFileSync(metadataPath, 'utf8'), { durable: true })) {
- rmSync(rollbackPath, { force: true })
- throw new Error('Could not restrict account metadata backup permissions.')
- }
- let next: ManagedDataAccountsState
- try {
- // Keep the original metadata until persistence and cleanup both succeed.
- next = this.writeState(provider, {
- accounts: state.accounts.filter((account) => account.id !== accountId),
- activeAccountId: state.activeAccountId === accountId ? null : state.activeAccountId
- })
- this.removeDirectory(directory)
- } catch (error) {
- renameSync(rollbackPath, metadataPath)
- throw error
- }
- try {
- rmSync(rollbackPath, { force: true })
- } catch {
- console.warn('[managed-data-accounts] Could not remove account metadata backup.')
- }
- this.notifyChanged()
- return next
- })
+ return this.mutate(() =>
+ this.profileRemoval.remove(
+ provider,
+ accountId,
+ this.list(provider),
+ (next) => this.writeState(provider, next),
+ () => this.notifyChanged()
+ )
+ )
}
launchEnvironment(provider: ManagedDataAccountProvider): Record {
From 1431547578d0776679cfc4ae30205eda3d43b51e Mon Sep 17 00:00:00 2001
From: Neil
Date: Fri, 2 Oct 2026 23:47:49 -0700
Subject: [PATCH 21/24] fix(accounts): canonicalize account removal to rm
Use account rm as the canonical removal command and keep account remove as an alias. Preserve the existing accounts.removeData RPC and the full original 63-path account component.
Original-Account-Source: cf71ae4cb6f8202a7cc8a424aa84d127c845cbe2
Frozen-Account-Base: 08ee7ba9efa2f3842b7a057a1eb101c824ea0087
Frozen-Integrated-Main: 53f9ea783986d0a336e079e8ed8d59a85552ca4c
Private validation source only; no ref or publication.
---
docs/reference/managed-data-accounts.md | 2 +-
src/cli/handler-group-manifest.ts | 2 +-
src/cli/handlers/account.ts | 4 +-
src/cli/root-help-text-primary.ts | 2 +-
src/cli/specs/account.test.ts | 53 ++++++++++++++++++++++++-
src/cli/specs/account.ts | 5 ++-
6 files changed, 59 insertions(+), 9 deletions(-)
diff --git a/docs/reference/managed-data-accounts.md b/docs/reference/managed-data-accounts.md
index 7967ab231e5a..c0a78d001d51 100644
--- a/docs/reference/managed-data-accounts.md
+++ b/docs/reference/managed-data-accounts.md
@@ -9,7 +9,7 @@ orca account add --agent devin --label Work
orca account list --agent opencode --json
orca account select --agent opencode --account
orca account select --agent opencode --account system
-orca account remove --agent opencode --account
+orca account rm --agent opencode --account
```
OpenCode enrollment requires OpenCode 2 and runs its official `auth login --standalone` command. Devin runs `auth login --force-manual-token-flow`; obtain the enrollment token through Devin's supported login flow. These commands neither reuse a guessed token nor sign out the system account. Settings → AI Provider Accounts provides the enrollment command, refresh, selection, and removal for the selected Orca host.
diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts
index c27ea5108263..6fd8588da2f8 100644
--- a/src/cli/handler-group-manifest.ts
+++ b/src/cli/handler-group-manifest.ts
@@ -19,7 +19,7 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
},
{
name: 'account',
- keys: ['account add', 'account list', 'account select', 'account remove'],
+ keys: ['account add', 'account list', 'account select', 'account rm'],
load: async () => (await import('./handlers/account.js')).ACCOUNT_HANDLERS
},
{
diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts
index 19d7cac1b49f..d4081c3c5da1 100644
--- a/src/cli/handlers/account.ts
+++ b/src/cli/handlers/account.ts
@@ -330,8 +330,8 @@ export const ACCOUNT_HANDLERS: Record = {
rejectAccountRemoteSelectionFlags(ctx, 'orca account select')
await mutateDataAccount(ctx, 'select')
},
- 'account remove': async (ctx) => {
- rejectAccountRemoteSelectionFlags(ctx, 'orca account remove')
+ 'account rm': async (ctx) => {
+ rejectAccountRemoteSelectionFlags(ctx, 'orca account rm')
await mutateDataAccount(ctx, 'remove')
}
}
diff --git a/src/cli/root-help-text-primary.ts b/src/cli/root-help-text-primary.ts
index 6d6a73537e2f..55d698c670a6 100644
--- a/src/cli/root-help-text-primary.ts
+++ b/src/cli/root-help-text-primary.ts
@@ -21,7 +21,7 @@ export const ROOT_HELP_TEXT_PRIMARY = [
' account add Add a managed agent account on this Orca host',
' account list List managed agent accounts on this Orca host',
' account select Select an OpenCode or Devin account for new launches',
- ' account remove Remove an OpenCode or Devin account and its private data',
+ ' account rm Remove an OpenCode or Devin account and its private data',
'',
'Skills:',
' skills installed List installed skill selectors',
diff --git a/src/cli/specs/account.test.ts b/src/cli/specs/account.test.ts
index 1ab5d61b116b..8cbe3f9c86a0 100644
--- a/src/cli/specs/account.test.ts
+++ b/src/cli/specs/account.test.ts
@@ -1,7 +1,15 @@
import { describe, expect, it } from 'vitest'
import { ACCOUNT_COMMAND_SPECS } from './account'
-import { effectiveAllowedFlags } from '../args'
+import {
+ effectiveAllowedFlags,
+ findCommandSpec,
+ normalizeCommandPositionals,
+ parseArgs,
+ specPaths,
+ validateCommandAndFlags
+} from '../args'
+import { HANDLER_COMMAND_KEYS } from '../dispatch'
import { formatCommandHelp } from '../help'
function spec(path: string): (typeof ACCOUNT_COMMAND_SPECS)[number] {
@@ -53,8 +61,49 @@ describe('account command specs', () => {
})
it('describes the supported providers for profile selection and removal', () => {
- for (const command of ['account list', 'account select', 'account remove']) {
+ for (const command of ['account list', 'account select', 'account rm']) {
expect(formatCommandHelp(spec(command))).toContain('Account provider: opencode or devin')
}
})
+
+ it.each([
+ ['rm', 'opencode'],
+ ['remove', 'opencode'],
+ ['rm', 'devin'],
+ ['remove', 'devin']
+ ])('routes account %s for %s through the canonical removal command', (verb, provider) => {
+ const paths = ACCOUNT_COMMAND_SPECS.flatMap(specPaths)
+ const parsed = normalizeCommandPositionals(
+ ACCOUNT_COMMAND_SPECS,
+ parseArgs(
+ ['account', verb, '--agent', provider, '--account', 'profile-1', '--json'],
+ paths,
+ ACCOUNT_COMMAND_SPECS
+ )
+ )
+
+ expect(parsed.commandPath).toEqual(['account', 'rm'])
+ expect(parsed.flags.get('agent')).toBe(provider)
+ expect(parsed.flags.get('account')).toBe('profile-1')
+ expect(parsed.flags.get('json')).toBe(true)
+ expect(() => validateCommandAndFlags(ACCOUNT_COMMAND_SPECS, parsed)).not.toThrow()
+ expect(HANDLER_COMMAND_KEYS.has(parsed.commandPath.join(' '))).toBe(true)
+ })
+
+ it('shows canonical removal help for the existing remove alias', () => {
+ const canonical = spec('account rm')
+
+ expect(findCommandSpec(ACCOUNT_COMMAND_SPECS, ['account', 'remove'])).toBe(canonical)
+ expect(formatCommandHelp(canonical)).toContain('orca account rm --agent opencode|devin')
+ expect(HANDLER_COMMAND_KEYS.has('account remove')).toBe(false)
+ })
+
+ it('rejects an unregistered account deletion verb', () => {
+ expect(() =>
+ validateCommandAndFlags(ACCOUNT_COMMAND_SPECS, {
+ commandPath: ['account', 'delete'],
+ flags: new Map()
+ })
+ ).toThrow('Unknown command: account delete')
+ })
})
diff --git a/src/cli/specs/account.ts b/src/cli/specs/account.ts
index 0411bdbd1d06..64bba5e9a1e9 100644
--- a/src/cli/specs/account.ts
+++ b/src/cli/specs/account.ts
@@ -40,9 +40,10 @@ export const ACCOUNT_COMMAND_SPECS: CommandSpec[] = [
allowedFlags: [...GLOBAL_FLAGS, 'agent', 'account']
},
{
- path: ['account', 'remove'],
+ path: ['account', 'rm'],
+ aliases: [['account', 'remove']],
summary: 'Remove a managed OpenCode or Devin profile and its private data',
- usage: 'orca account remove --agent opencode|devin --account [--json]',
+ usage: 'orca account rm --agent opencode|devin --account [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'agent', 'account'],
notes: [
'Deletes credentials and conversation data in the managed profile. Stop its running agents first. System credentials are never removed.'
From c2fb8c89c414d20070751484f354cdd522de8e1a Mon Sep 17 00:00:00 2001
From: Neil
Date: Sat, 3 Oct 2026 00:46:17 -0700
Subject: [PATCH 22/24] fix(accounts): recover interrupted profile removal on
startup
Scan private removal backups before quarantined cleanup, reuse the existing state schema to validate the exact UUID, and preserve registered or unmarked profiles. Mark account rm as destructive using the existing typo recovery policy. Retain the full original account component and public author ancestry.
Account-PR: 24636
Original-Account-Source: cf71ae4cb6f8202a7cc8a424aa84d127c845cbe2
Reviewed-Public-Parent: 6abaf736e3c302affdb9c2fb0e911e14baf62739
Frozen-Integrated-Main: 53f9ea783986d0a336e079e8ed8d59a85552ca4c
Private source only; no ref or publication.
---
src/cli/specs/account.test.ts | 27 ++
src/cli/specs/account.ts | 1 +
.../profile-removal-recovery.test.ts | 304 ++++++++++++++++++
.../managed-data-accounts/profile-removal.ts | 69 +++-
src/main/managed-data-accounts/service.ts | 1 +
5 files changed, 395 insertions(+), 7 deletions(-)
create mode 100644 src/main/managed-data-accounts/profile-removal-recovery.test.ts
diff --git a/src/cli/specs/account.test.ts b/src/cli/specs/account.test.ts
index 8cbe3f9c86a0..6a4e209c3680 100644
--- a/src/cli/specs/account.test.ts
+++ b/src/cli/specs/account.test.ts
@@ -11,6 +11,7 @@ import {
} from '../args'
import { HANDLER_COMMAND_KEYS } from '../dispatch'
import { formatCommandHelp } from '../help'
+import { suggestCommands, unknownCommandData } from '../command-suggestion'
function spec(path: string): (typeof ACCOUNT_COMMAND_SPECS)[number] {
const found = ACCOUNT_COMMAND_SPECS.find((entry) => entry.path.join(' ') === path)
@@ -98,6 +99,32 @@ describe('account command specs', () => {
expect(HANDLER_COMMAND_KEYS.has('account remove')).toBe(false)
})
+ it.each(['move', 'go'])(
+ 'suggestion safety keeps benign account %s mistakes out of profile deletion',
+ (verb) => {
+ const path = ['account', verb]
+ expect(suggestCommands(ACCOUNT_COMMAND_SPECS, path)).not.toContain('account rm')
+ expect(suggestCommands(ACCOUNT_COMMAND_SPECS, path)).not.toContain('account remove')
+ const data = unknownCommandData(ACCOUNT_COMMAND_SPECS, path)
+ expect(data.nextSteps.join(' ')).not.toContain('orca account rm')
+ expect(data.nextSteps.join(' ')).not.toContain('orca account remove')
+ }
+ )
+
+ it('suggestion safety still recovers intended profile removal near-misses', () => {
+ const data = unknownCommandData(ACCOUNT_COMMAND_SPECS, ['account', 'remov'])
+ expect(data.suggestions).toContain('account rm')
+ expect(data.suggestions).toContain('account remove')
+ expect(data.nextSteps.join(' ')).toContain('orca account rm')
+ })
+
+ it('suggestion safety preserves non-destructive account list recovery', () => {
+ const suggestions = suggestCommands(ACCOUNT_COMMAND_SPECS, ['account', 'lst'])
+ expect(suggestions).toContain('account list')
+ expect(suggestions).not.toContain('account rm')
+ expect(suggestions).not.toContain('account remove')
+ })
+
it('rejects an unregistered account deletion verb', () => {
expect(() =>
validateCommandAndFlags(ACCOUNT_COMMAND_SPECS, {
diff --git a/src/cli/specs/account.ts b/src/cli/specs/account.ts
index 64bba5e9a1e9..3da205e31eb5 100644
--- a/src/cli/specs/account.ts
+++ b/src/cli/specs/account.ts
@@ -42,6 +42,7 @@ export const ACCOUNT_COMMAND_SPECS: CommandSpec[] = [
{
path: ['account', 'rm'],
aliases: [['account', 'remove']],
+ destructive: true,
summary: 'Remove a managed OpenCode or Devin profile and its private data',
usage: 'orca account rm --agent opencode|devin --account [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'agent', 'account'],
diff --git a/src/main/managed-data-accounts/profile-removal-recovery.test.ts b/src/main/managed-data-accounts/profile-removal-recovery.test.ts
new file mode 100644
index 000000000000..f0ccc305d6f1
--- /dev/null
+++ b/src/main/managed-data-accounts/profile-removal-recovery.test.ts
@@ -0,0 +1,304 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { randomUUID } from 'node:crypto'
+import * as fileSystem from 'node:fs'
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ symlinkSync,
+ writeFileSync
+} from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import type { ManagedDataAccountProvider } from '../../shared/managed-account-types'
+import { writeSecureFile } from '../../shared/secure-file'
+import { ManagedDataAccountService } from './service'
+
+vi.mock('node:fs', async (importOriginal) => ({
+ ...(await importOriginal())
+}))
+
+let root: string
+let storage: string
+
+beforeEach(() => {
+ root = mkdtempSync(join(tmpdir(), 'orca-account-removal-recovery-'))
+ storage = join(root, 'managed')
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ rmSync(root, { recursive: true, force: true })
+})
+
+function interruptedRemoval(provider: ManagedDataAccountProvider = 'devin') {
+ const id = randomUUID()
+ const providerRoot = join(storage, provider)
+ const directory = join(providerRoot, id)
+ const credentialsPath = join(directory, 'data', 'test-credentials')
+ const metadataPath = join(providerRoot, 'accounts.json')
+ const rollbackPath = `${metadataPath}.${id}.rollback`
+ const before = {
+ accounts: [{ id, label: 'Private test profile', integrations: [], createdAt: 1 }],
+ activeAccountId: id
+ }
+ mkdirSync(join(directory, 'data'), { recursive: true })
+ writeFileSync(credentialsPath, 'private-test-only-credential')
+ expect(writeSecureFile(rollbackPath, JSON.stringify(before), { durable: true })).toBe(true)
+ expect(
+ writeSecureFile(metadataPath, JSON.stringify({ accounts: [], activeAccountId: null }), {
+ durable: true
+ })
+ ).toBe(true)
+ return { id, providerRoot, directory, credentialsPath, metadataPath, rollbackPath, before }
+}
+
+async function finishStartup(service: ManagedDataAccountService) {
+ await service.select('devin', null)
+}
+
+describe('interrupted managed account removal recovery', () => {
+ it.each(['devin', 'opencode'] as const)(
+ 'quarantines a committed %s removal left before quarantine on restart',
+ async (provider) => {
+ const fixture = interruptedRemoval(provider)
+ const restarted = new ManagedDataAccountService(storage)
+ expect(restarted.list(provider)).toEqual({ accounts: [], activeAccountId: null })
+ expect(restarted.launchEnvironment(provider)).toEqual({})
+ await finishStartup(restarted)
+ expect(existsSync(fixture.directory)).toBe(false)
+ expect(existsSync(fixture.rollbackPath)).toBe(false)
+ expect(restarted.list(provider)).toEqual({ accounts: [], activeAccountId: null })
+ }
+ )
+
+ it('recovers after both quarantine and metadata rollback fail without the removed id', async () => {
+ const source = join(root, 'source')
+ mkdirSync(join(source, 'devin'), { recursive: true })
+ writeFileSync(join(source, 'devin', 'credentials.toml'), 'windsurf_api_key = "test-only"')
+ const service = new ManagedDataAccountService(storage)
+ const before = await service.add('devin', source, 'Work')
+ const id = before.accounts[0].id
+ const directory = join(storage, 'devin', id)
+ const credentialsPath = join(directory, 'data', 'devin', 'credentials.toml')
+ const metadataPath = join(storage, 'devin', 'accounts.json')
+ const rollbackPath = `${metadataPath}.${id}.rollback`
+ const credentials = readFileSync(credentialsPath)
+ const original = readFileSync(metadataPath)
+ const rename = fileSystem.renameSync
+ const failingRename = vi.spyOn(fileSystem, 'renameSync').mockImplementation((from, to) => {
+ if (from === directory || from === rollbackPath) {
+ throw new Error('injected removal and rollback lock')
+ }
+ return rename(from, to)
+ })
+ await expect(service.remove('devin', id)).rejects.toBeInstanceOf(AggregateError)
+ expect(service.list('devin')).toEqual({ accounts: [], activeAccountId: null })
+ expect(readFileSync(credentialsPath)).toEqual(credentials)
+ expect(readFileSync(rollbackPath)).toEqual(original)
+ failingRename.mockRestore()
+
+ const restarted = new ManagedDataAccountService(storage)
+ await finishStartup(restarted)
+ expect(existsSync(directory)).toBe(false)
+ expect(existsSync(rollbackPath)).toBe(false)
+ expect(restarted.list('devin')).toEqual({ accounts: [], activeAccountId: null })
+ expect(readFileSync(join(source, 'devin', 'credentials.toml'), 'utf8')).toContain('test-only')
+ })
+
+ it('retains recovery evidence when quarantine is locked and continues other removals', async () => {
+ const locked = interruptedRemoval()
+ const other = interruptedRemoval()
+ const rename = fileSystem.renameSync
+ const failingRename = vi.spyOn(fileSystem, 'renameSync').mockImplementation((from, to) => {
+ if (from === locked.directory) {
+ throw new Error('quarantine locked')
+ }
+ return rename(from, to)
+ })
+ const restarted = new ManagedDataAccountService(storage)
+ await finishStartup(restarted)
+ expect(existsSync(locked.directory)).toBe(true)
+ expect(readFileSync(locked.credentialsPath, 'utf8')).toBe('private-test-only-credential')
+ expect(existsSync(locked.rollbackPath)).toBe(true)
+ expect(existsSync(other.directory)).toBe(false)
+ expect(existsSync(other.rollbackPath)).toBe(false)
+ failingRename.mockRestore()
+
+ const retry = new ManagedDataAccountService(storage)
+ await finishStartup(retry)
+ expect(existsSync(locked.directory)).toBe(false)
+ expect(existsSync(locked.rollbackPath)).toBe(false)
+ })
+
+ it('leaves failed cleanup quarantined and retries it without restoring metadata', async () => {
+ const fixture = interruptedRemoval()
+ const restarted = new ManagedDataAccountService(storage, () => {
+ throw new Error('cleanup locked')
+ })
+ await finishStartup(restarted)
+ const pendingDirectory = join(fixture.providerRoot, '.pending-delete', fixture.id)
+ expect(existsSync(fixture.directory)).toBe(false)
+ expect(readFileSync(join(pendingDirectory, 'data', 'test-credentials'), 'utf8')).toBe(
+ 'private-test-only-credential'
+ )
+ expect(restarted.list('devin')).toEqual({ accounts: [], activeAccountId: null })
+
+ const retry = new ManagedDataAccountService(storage)
+ await finishStartup(retry)
+ expect(existsSync(pendingDirectory)).toBe(false)
+ expect(existsSync(fixture.rollbackPath)).toBe(false)
+ expect(retry.list('devin')).toEqual({ accounts: [], activeAccountId: null })
+ })
+
+ it('retains backup evidence while an original profile collides with its quarantine', async () => {
+ const fixture = interruptedRemoval()
+ const pendingDirectory = join(fixture.providerRoot, '.pending-delete', fixture.id)
+ mkdirSync(pendingDirectory, { recursive: true })
+ writeFileSync(join(pendingDirectory, 'private-state'), 'earlier-quarantine-test-data')
+ const restarted = new ManagedDataAccountService(storage)
+ await finishStartup(restarted)
+ expect(existsSync(pendingDirectory)).toBe(false)
+ expect(readFileSync(fixture.credentialsPath, 'utf8')).toBe('private-test-only-credential')
+ expect(existsSync(fixture.rollbackPath)).toBe(true)
+ expect(restarted.list('devin')).toEqual({ accounts: [], activeAccountId: null })
+
+ const retry = new ManagedDataAccountService(storage)
+ await finishStartup(retry)
+ expect(existsSync(fixture.directory)).toBe(false)
+ expect(existsSync(fixture.rollbackPath)).toBe(false)
+ expect(retry.list('devin')).toEqual({ accounts: [], activeAccountId: null })
+ })
+
+ it('preserves registered profiles, their markers and quarantines, and unmarked directories', async () => {
+ const fixture = interruptedRemoval()
+ expect(writeSecureFile(fixture.metadataPath, JSON.stringify(fixture.before))).toBe(true)
+ const pendingDirectory = join(fixture.providerRoot, '.pending-delete', fixture.id)
+ const unmarked = join(fixture.providerRoot, randomUUID())
+ mkdirSync(pendingDirectory, { recursive: true })
+ mkdirSync(unmarked)
+ writeFileSync(join(unmarked, 'private-data'), 'unregistered-is-not-removal-evidence')
+ const marker = readFileSync(fixture.rollbackPath)
+ const restarted = new ManagedDataAccountService(storage)
+ await restarted.select('devin', fixture.id)
+ expect(readFileSync(fixture.credentialsPath, 'utf8')).toBe('private-test-only-credential')
+ expect(readFileSync(fixture.rollbackPath)).toEqual(marker)
+ expect(existsSync(pendingDirectory)).toBe(true)
+ expect(readFileSync(join(unmarked, 'private-data'), 'utf8')).toBe(
+ 'unregistered-is-not-removal-evidence'
+ )
+ expect(restarted.list('devin')).toEqual(fixture.before)
+ })
+
+ it.each(['invalid JSON', 'invalid state', 'different id', 'directory', 'missing marker'])(
+ 'preserves original credentials without valid removal evidence: %s',
+ async (invalid) => {
+ const fixture = interruptedRemoval()
+ if (invalid === 'invalid JSON') {
+ writeFileSync(fixture.rollbackPath, '{')
+ } else if (invalid === 'invalid state') {
+ writeFileSync(fixture.rollbackPath, JSON.stringify({ accounts: [{ id: fixture.id }] }))
+ } else if (invalid === 'different id') {
+ writeFileSync(
+ fixture.rollbackPath,
+ JSON.stringify({ ...fixture.before, accounts: [], activeAccountId: null })
+ )
+ } else {
+ rmSync(fixture.rollbackPath)
+ if (invalid === 'directory') {
+ mkdirSync(fixture.rollbackPath)
+ }
+ }
+ const restarted = new ManagedDataAccountService(storage)
+ await finishStartup(restarted)
+ expect(readFileSync(fixture.credentialsPath, 'utf8')).toBe('private-test-only-credential')
+ await expect(restarted.remove('devin', fixture.id)).rejects.toThrow()
+ expect(readFileSync(fixture.credentialsPath, 'utf8')).toBe('private-test-only-credential')
+ }
+ )
+
+ it('preserves unreadable backups and continues other eligible removals', async () => {
+ const unreadable = interruptedRemoval()
+ const other = interruptedRemoval()
+ const read = fileSystem.readFileSync
+ const failingRead = vi.spyOn(fileSystem, 'readFileSync').mockImplementation((...args) => {
+ if (args[0] === unreadable.rollbackPath) {
+ throw Object.assign(new Error('private backup unreadable'), { code: 'EACCES' })
+ }
+ return read(...args)
+ })
+ const restarted = new ManagedDataAccountService(storage)
+ await finishStartup(restarted)
+ expect(readFileSync(unreadable.credentialsPath, 'utf8')).toBe('private-test-only-credential')
+ expect(existsSync(unreadable.rollbackPath)).toBe(true)
+ expect(existsSync(other.directory)).toBe(false)
+ failingRead.mockRestore()
+
+ const retry = new ManagedDataAccountService(storage)
+ await finishStartup(retry)
+ expect(existsSync(unreadable.directory)).toBe(false)
+ expect(existsSync(unreadable.rollbackPath)).toBe(false)
+ })
+
+ it('rejects a linked provider directory without deleting another provider profile', async () => {
+ const fixture = interruptedRemoval('opencode')
+ const alias = join(storage, 'devin')
+ symlinkSync(fixture.providerRoot, alias, 'junction')
+ expect(writeSecureFile(fixture.metadataPath, JSON.stringify(fixture.before))).toBe(true)
+ const restarted = new ManagedDataAccountService(storage)
+ await restarted.select('opencode', fixture.id)
+ expect(console.warn).toHaveBeenCalledWith(
+ '[managed-data-accounts] Could not retry private account directory cleanup.'
+ )
+ expect(readFileSync(fixture.credentialsPath, 'utf8')).toBe('private-test-only-credential')
+ expect(existsSync(fixture.rollbackPath)).toBe(true)
+ })
+
+ it('ignores non-UUID markers and cleans only valid completed removal markers', async () => {
+ const fixture = interruptedRemoval()
+ rmSync(fixture.directory, { recursive: true })
+ const invalid = join(fixture.providerRoot, 'accounts.json.unrecognized.rollback')
+ writeFileSync(invalid, JSON.stringify(fixture.before))
+ const restarted = new ManagedDataAccountService(storage)
+ await finishStartup(restarted)
+ expect(existsSync(fixture.rollbackPath)).toBe(false)
+ expect(existsSync(invalid)).toBe(true)
+ })
+
+ it.skipIf(process.platform === 'win32')(
+ 'rejects a marker symlink without touching its target',
+ async () => {
+ const fixture = interruptedRemoval()
+ const target = join(root, 'outside-backup')
+ const backup = readFileSync(fixture.rollbackPath)
+ writeFileSync(target, backup)
+ rmSync(fixture.rollbackPath)
+ symlinkSync(target, fixture.rollbackPath)
+ const restarted = new ManagedDataAccountService(storage)
+ await finishStartup(restarted)
+ expect(readFileSync(fixture.credentialsPath, 'utf8')).toBe('private-test-only-credential')
+ await expect(restarted.remove('devin', fixture.id)).rejects.toThrow()
+ expect(readFileSync(target)).toEqual(backup)
+ expect(readFileSync(fixture.credentialsPath, 'utf8')).toBe('private-test-only-credential')
+ }
+ )
+
+ it.skipIf(process.platform === 'win32')(
+ 'rejects a profile symlink without touching its target',
+ async () => {
+ const fixture = interruptedRemoval()
+ const target = join(root, 'outside-profile')
+ mkdirSync(target)
+ writeFileSync(join(target, 'private-data'), 'outside-private-test-data')
+ rmSync(fixture.directory, { recursive: true })
+ symlinkSync(target, fixture.directory)
+ const restarted = new ManagedDataAccountService(storage)
+ await finishStartup(restarted)
+ expect(readFileSync(join(target, 'private-data'), 'utf8')).toBe('outside-private-test-data')
+ expect(existsSync(fixture.rollbackPath)).toBe(true)
+ }
+ )
+})
diff --git a/src/main/managed-data-accounts/profile-removal.ts b/src/main/managed-data-accounts/profile-removal.ts
index 25f678317e80..14c7c0c4adaf 100644
--- a/src/main/managed-data-accounts/profile-removal.ts
+++ b/src/main/managed-data-accounts/profile-removal.ts
@@ -1,4 +1,4 @@
-import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs'
+import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs'
import { readdir } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { z } from 'zod'
@@ -13,6 +13,7 @@ export class ManagedDataAccountProfileRemoval {
constructor(
private readonly root: string,
private readonly assertOwned: (path: string) => void,
+ private readonly parseState: (contents: string) => ManagedDataAccountsState,
private readonly removeDirectory: (directory: string) => void | Promise = removeHostTree
) {}
@@ -31,11 +32,13 @@ export class ManagedDataAccountProfileRemoval {
const metadataPath = join(this.root, provider, 'accounts.json')
const rollbackPath = `${metadataPath}.${accountId}.rollback`
if (!state.accounts.some((account) => account.id === accountId)) {
- if (existsSync(rollbackPath) && existsSync(directory)) {
- this.assertOwned(rollbackPath)
+ if (existsSync(directory)) {
+ if (!this.hasRemovalBackup(rollbackPath, accountId)) {
+ throw new Error('Managed account not found.')
+ }
this.quarantine(directory, pendingDirectory)
changed()
- } else if (!existsSync(pendingDirectory)) {
+ } else if (!existsSync(pendingDirectory) && !this.hasRemovalBackup(rollbackPath, accountId)) {
throw new Error('Managed account not found.')
}
this.discardBackup(rollbackPath)
@@ -79,6 +82,7 @@ export class ManagedDataAccountProfileRemoval {
if (!existsSync(directory)) {
return
}
+ this.assertOwned(dirname(directory))
this.assertOwned(directory)
if (existsSync(pendingDirectory)) {
throw new Error('Account already has a pending removal directory.')
@@ -89,6 +93,49 @@ export class ManagedDataAccountProfileRemoval {
renameSync(directory, pendingDirectory)
}
+ private hasRemovalBackup(rollbackPath: string, accountId: string): boolean {
+ if (!existsSync(rollbackPath)) {
+ return false
+ }
+ this.assertOwned(dirname(rollbackPath))
+ this.assertOwned(rollbackPath)
+ if (!lstatSync(rollbackPath).isFile()) {
+ return false
+ }
+ try {
+ const backup = this.parseState(readFileSync(rollbackPath, 'utf8'))
+ return backup.accounts.some((account) => account.id === accountId)
+ } catch {
+ return false
+ }
+ }
+
+ private async retryBackups(providerRoot: string, registered: Set): Promise {
+ const entries = await readdir(providerRoot, { withFileTypes: true })
+ for (const entry of entries) {
+ const accountId = /^accounts\.json\.(.+)\.rollback$/.exec(entry.name)?.[1]
+ if (
+ !entry.isFile() ||
+ !accountId ||
+ !z.uuid().safeParse(accountId).success ||
+ registered.has(accountId)
+ ) {
+ continue
+ }
+ const rollbackPath = join(providerRoot, entry.name)
+ try {
+ if (!this.hasRemovalBackup(rollbackPath, accountId)) {
+ continue
+ }
+ const pendingDirectory = join(providerRoot, '.pending-delete', accountId)
+ this.quarantine(join(providerRoot, accountId), pendingDirectory)
+ this.discardBackup(rollbackPath)
+ } catch {
+ console.warn('[managed-data-accounts] Could not recover interrupted account removal.')
+ }
+ }
+ }
+
private discardBackup(rollbackPath: string): void {
try {
rmSync(rollbackPath, { force: true })
@@ -113,11 +160,17 @@ export class ManagedDataAccountProfileRemoval {
}
async retry(provider: ManagedDataAccountProvider, registered: Set): Promise {
- const pendingRoot = join(this.root, provider, '.pending-delete')
- if (!existsSync(pendingRoot)) {
+ const providerRoot = join(this.root, provider)
+ if (!existsSync(providerRoot)) {
return
}
try {
+ this.assertOwned(providerRoot)
+ await this.retryBackups(providerRoot, registered)
+ const pendingRoot = join(providerRoot, '.pending-delete')
+ if (!existsSync(pendingRoot)) {
+ return
+ }
this.assertOwned(pendingRoot)
const entries = await readdir(pendingRoot, { withFileTypes: true })
for (const entry of entries) {
@@ -129,7 +182,9 @@ export class ManagedDataAccountProfileRemoval {
continue
}
await this.cleanup(join(pendingRoot, entry.name))
- this.discardBackup(join(this.root, provider, `accounts.json.${entry.name}.rollback`))
+ if (!existsSync(join(providerRoot, entry.name))) {
+ this.discardBackup(join(providerRoot, `accounts.json.${entry.name}.rollback`))
+ }
}
} catch {
console.warn('[managed-data-accounts] Could not retry private account directory cleanup.')
diff --git a/src/main/managed-data-accounts/service.ts b/src/main/managed-data-accounts/service.ts
index 180565d23d4d..5ebf2ae91f25 100644
--- a/src/main/managed-data-accounts/service.ts
+++ b/src/main/managed-data-accounts/service.ts
@@ -47,6 +47,7 @@ export class ManagedDataAccountService {
this.profileRemoval = new ManagedDataAccountProfileRemoval(
root,
(path) => this.assertOwned(path),
+ (contents) => stateSchema.parse(JSON.parse(contents)),
removeDirectory
)
if (existsSync(root)) {
From 28739cd94e2ba1ee43068e2f0658dd42164e90a4 Mon Sep 17 00:00:00 2001
From: Neil
Date: Sat, 3 Oct 2026 01:10:07 -0700
Subject: [PATCH 23/24] fix(accounts): protect registered UUID case variants
during removal recovery
Compare validated account UUID identities without changing stored IDs or filesystem paths. Protect registered originals and quarantines, including explicit retry variants, and accept equivalent UUID spelling in a valid removal backup. Retain the complete account component and published contributor ancestry.
Private source only; no index, ref, or public mutation.
---
.../profile-removal-recovery.test.ts | 73 ++++++++++++++++++-
.../managed-data-accounts/profile-removal.ts | 13 +++-
2 files changed, 80 insertions(+), 6 deletions(-)
diff --git a/src/main/managed-data-accounts/profile-removal-recovery.test.ts b/src/main/managed-data-accounts/profile-removal-recovery.test.ts
index f0ccc305d6f1..ec3035ba6344 100644
--- a/src/main/managed-data-accounts/profile-removal-recovery.test.ts
+++ b/src/main/managed-data-accounts/profile-removal-recovery.test.ts
@@ -34,8 +34,10 @@ afterEach(() => {
rmSync(root, { recursive: true, force: true })
})
-function interruptedRemoval(provider: ManagedDataAccountProvider = 'devin') {
- const id = randomUUID()
+function interruptedRemoval(
+ provider: ManagedDataAccountProvider = 'devin',
+ id: string = randomUUID()
+) {
const providerRoot = join(storage, provider)
const directory = join(providerRoot, id)
const credentialsPath = join(directory, 'data', 'test-credentials')
@@ -193,6 +195,73 @@ describe('interrupted managed account removal recovery', () => {
expect(restarted.list('devin')).toEqual(fixture.before)
})
+ it.each(['lowercase', 'uppercase'] as const)(
+ 'preserves original credentials registered with a %s UUID spelling',
+ async (spelling) => {
+ const lowerId = randomUUID()
+ const registeredId = spelling === 'lowercase' ? lowerId : lowerId.toUpperCase()
+ const markerId = spelling === 'lowercase' ? lowerId.toUpperCase() : lowerId
+ const fixture = interruptedRemoval('devin', markerId)
+ const registered = {
+ accounts: [{ ...fixture.before.accounts[0], id: registeredId }],
+ activeAccountId: registeredId
+ }
+ expect(writeSecureFile(fixture.metadataPath, JSON.stringify(registered))).toBe(true)
+ const marker = readFileSync(fixture.rollbackPath)
+ const restarted = new ManagedDataAccountService(storage)
+ await finishStartup(restarted)
+ expect(readFileSync(fixture.credentialsPath, 'utf8')).toBe('private-test-only-credential')
+ expect(readFileSync(fixture.rollbackPath)).toEqual(marker)
+ await expect(restarted.remove('devin', markerId)).rejects.toThrow(
+ 'Managed account not found.'
+ )
+ expect(readFileSync(fixture.credentialsPath, 'utf8')).toBe('private-test-only-credential')
+ expect(restarted.list('devin').accounts).toEqual(registered.accounts)
+ }
+ )
+
+ it.each(['lowercase', 'uppercase'] as const)(
+ 'preserves a quarantine registered with a %s UUID spelling',
+ async (spelling) => {
+ const lowerId = randomUUID()
+ const registeredId = spelling === 'lowercase' ? lowerId : lowerId.toUpperCase()
+ const markerId = spelling === 'lowercase' ? lowerId.toUpperCase() : lowerId
+ const fixture = interruptedRemoval('devin', markerId)
+ const registered = {
+ accounts: [{ ...fixture.before.accounts[0], id: registeredId }],
+ activeAccountId: registeredId
+ }
+ expect(writeSecureFile(fixture.metadataPath, JSON.stringify(registered))).toBe(true)
+ const pendingDirectory = join(fixture.providerRoot, '.pending-delete', markerId)
+ const pendingCredentials = join(pendingDirectory, 'data', 'test-credentials')
+ mkdirSync(join(fixture.providerRoot, '.pending-delete'), { recursive: true })
+ fileSystem.renameSync(fixture.directory, pendingDirectory)
+ const marker = readFileSync(fixture.rollbackPath)
+ const restarted = new ManagedDataAccountService(storage)
+ await finishStartup(restarted)
+ expect(readFileSync(pendingCredentials, 'utf8')).toBe('private-test-only-credential')
+ expect(readFileSync(fixture.rollbackPath)).toEqual(marker)
+ await expect(restarted.remove('devin', markerId)).rejects.toThrow(
+ 'Managed account not found.'
+ )
+ expect(readFileSync(pendingCredentials, 'utf8')).toBe('private-test-only-credential')
+ expect(restarted.list('devin').accounts).toEqual(registered.accounts)
+ }
+ )
+
+ it('recovers an unregistered profile when its marker contains another UUID spelling', async () => {
+ const fixture = interruptedRemoval('devin', randomUUID().toUpperCase())
+ const before = {
+ accounts: [{ ...fixture.before.accounts[0], id: fixture.id.toLowerCase() }],
+ activeAccountId: fixture.id.toLowerCase()
+ }
+ expect(writeSecureFile(fixture.rollbackPath, JSON.stringify(before))).toBe(true)
+ const restarted = new ManagedDataAccountService(storage)
+ await finishStartup(restarted)
+ expect(existsSync(fixture.directory)).toBe(false)
+ expect(existsSync(fixture.rollbackPath)).toBe(false)
+ })
+
it.each(['invalid JSON', 'invalid state', 'different id', 'directory', 'missing marker'])(
'preserves original credentials without valid removal evidence: %s',
async (invalid) => {
diff --git a/src/main/managed-data-accounts/profile-removal.ts b/src/main/managed-data-accounts/profile-removal.ts
index 14c7c0c4adaf..9cd83379054e 100644
--- a/src/main/managed-data-accounts/profile-removal.ts
+++ b/src/main/managed-data-accounts/profile-removal.ts
@@ -32,6 +32,9 @@ export class ManagedDataAccountProfileRemoval {
const metadataPath = join(this.root, provider, 'accounts.json')
const rollbackPath = `${metadataPath}.${accountId}.rollback`
if (!state.accounts.some((account) => account.id === accountId)) {
+ if (state.accounts.some((account) => account.id.toLowerCase() === accountId.toLowerCase())) {
+ throw new Error('Managed account not found.')
+ }
if (existsSync(directory)) {
if (!this.hasRemovalBackup(rollbackPath, accountId)) {
throw new Error('Managed account not found.')
@@ -104,7 +107,7 @@ export class ManagedDataAccountProfileRemoval {
}
try {
const backup = this.parseState(readFileSync(rollbackPath, 'utf8'))
- return backup.accounts.some((account) => account.id === accountId)
+ return backup.accounts.some((account) => account.id.toLowerCase() === accountId.toLowerCase())
} catch {
return false
}
@@ -118,7 +121,7 @@ export class ManagedDataAccountProfileRemoval {
!entry.isFile() ||
!accountId ||
!z.uuid().safeParse(accountId).success ||
- registered.has(accountId)
+ registered.has(accountId.toLowerCase())
) {
continue
}
@@ -166,7 +169,9 @@ export class ManagedDataAccountProfileRemoval {
}
try {
this.assertOwned(providerRoot)
- await this.retryBackups(providerRoot, registered)
+ // UUID case variants may name the same directory.
+ const registeredIds = new Set([...registered].map((id) => id.toLowerCase()))
+ await this.retryBackups(providerRoot, registeredIds)
const pendingRoot = join(providerRoot, '.pending-delete')
if (!existsSync(pendingRoot)) {
return
@@ -177,7 +182,7 @@ export class ManagedDataAccountProfileRemoval {
if (
!entry.isDirectory() ||
!z.uuid().safeParse(entry.name).success ||
- registered.has(entry.name)
+ registeredIds.has(entry.name.toLowerCase())
) {
continue
}
From 70bc58df5eb269d7d7bad9796f8d56d6e5a8af4f Mon Sep 17 00:00:00 2001
From: Neil
Date: Sat, 3 Oct 2026 02:59:01 -0700
Subject: [PATCH 24/24] Allow cold node-pty setup on Windows ARM CI
Keep a ten-minute bound only for node-pty rebuilt on Windows ARM CI hosts; all other node-gyp calls retain five minutes. Cold setup in two completed ARM jobs left compilation less than a minute.
---
config/scripts/ensure-native-runtime.mjs | 6 ++--
config/scripts/ensure-native-runtime.test.mjs | 15 ++++++++--
.../windows-process-tree-gyp-rebuild.mjs | 10 +++++++
.../windows-process-tree-gyp-rebuild.test.mjs | 28 +++++++++++++++++++
4 files changed, 55 insertions(+), 4 deletions(-)
diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs
index bee1b3fed9ee..a91b4f528c4f 100644
--- a/config/scripts/ensure-native-runtime.mjs
+++ b/config/scripts/ensure-native-runtime.mjs
@@ -9,6 +9,7 @@ import {
ensureWindowsProcessTreeCommandLinePatch,
inspectWindowsProcessTreeAddon,
nodeGypRebuildInvocation,
+ nodeGypRebuildTimeoutMs,
stageWindowsProcessTreeNodeAddonApiHeaders,
windowsProcessTreeAddonPath
} from './windows-process-tree-gyp-rebuild.mjs'
@@ -405,6 +406,7 @@ function rebuildNodeRuntimeModules(moduleNames) {
console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`)
// pnpm exec inside an installed addon cannot discover the root build tool.
runNodeGyp(
+ moduleName,
nodeGypRebuildInvocation(
process.arch,
moduleDir,
@@ -417,7 +419,7 @@ function rebuildNodeRuntimeModules(moduleNames) {
}
}
-function runNodeGyp({ args, cwd }) {
+function runNodeGyp(moduleName, { args, cwd }) {
const env =
process.platform === 'linux'
? { ...process.env, CXXFLAGS: `${process.env.CXXFLAGS ?? ''} -std=gnu++2a`.trim() }
@@ -428,7 +430,7 @@ function runNodeGyp({ args, cwd }) {
cwd,
env,
stdio: 'inherit',
- timeoutMs: 300_000
+ timeoutMs: nodeGypRebuildTimeoutMs(moduleName)
})
if (result.code !== 0) {
console.error(
diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs
index 3cd01374d1f3..68a10b5bb206 100644
--- a/config/scripts/ensure-native-runtime.test.mjs
+++ b/config/scripts/ensure-native-runtime.test.mjs
@@ -20,7 +20,10 @@ import { resolveCliCommand } from '../../src/shared/node-cli-command-resolution.
import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal.ts'
import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs'
import { copyScriptWithLocalModules } from './script-module-dependencies.mjs'
-import { nodeGypRebuildInvocation } from './windows-process-tree-gyp-rebuild.mjs'
+import {
+ nodeGypRebuildInvocation,
+ nodeGypRebuildTimeoutMs
+} from './windows-process-tree-gyp-rebuild.mjs'
const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url))
// The import walk sees `from './x.mjs'` only, so the createRequire'd CJS
@@ -60,6 +63,7 @@ describe('ensure-native-runtime', () => {
expect(result.stderr).toContain('node-gyp stderr complete\n')
const log = readFileSync(logPath, 'utf8')
expect(log).toContain(`node-gyp rebuild --arch=${process.arch}\n`)
+ expect(log).toContain(`node-gyp timeout=${nodeGypRebuildTimeoutMs('node-pty')}\n`)
expect(log).toContain(`trackFileAccess=${process.platform === 'win32' ? 'false' : ''}\n`)
expect(log).toContain(join('node_modules', 'node-pty'))
if (process.platform === 'linux') {
@@ -275,7 +279,14 @@ function mkTempProject() {
copyScriptWithLocalModules(sourceScriptPath, join(projectDir, 'config', 'scripts'))
writeFileSync(
join(projectDir, 'config', 'scripts', 'script-child-process.mjs'),
- `export { describeProcessFailure, runProcessSync } from ${JSON.stringify(new URL('./script-child-process.mjs', import.meta.url).href)}\n`
+ `import { appendFileSync } from 'node:fs'
+import { describeProcessFailure, runProcessSync as run } from ${JSON.stringify(new URL('./script-child-process.mjs', import.meta.url).href)}
+export { describeProcessFailure }
+export function runProcessSync(options) {
+ appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-gyp timeout=\${options.timeoutMs}\\n\`)
+ return run(options)
+}
+`
)
for (const name of REQUIRED_CJS_SIBLINGS) {
copyFileSync(
diff --git a/config/scripts/windows-process-tree-gyp-rebuild.mjs b/config/scripts/windows-process-tree-gyp-rebuild.mjs
index c64df18fa899..f782334628d0 100644
--- a/config/scripts/windows-process-tree-gyp-rebuild.mjs
+++ b/config/scripts/windows-process-tree-gyp-rebuild.mjs
@@ -65,6 +65,16 @@ export function nodeGypRebuildInvocation(
}
}
+export function nodeGypRebuildTimeoutMs(
+ moduleName,
+ { platform = process.platform, arch = process.arch, ci = process.env.CI } = {}
+) {
+ // Cold headers and toolchain discovery consumed over four minutes on Windows ARM CI.
+ return moduleName === 'node-pty' && platform === 'win32' && arch === 'arm64' && ci === 'true'
+ ? 600_000
+ : 300_000
+}
+
/** The binary the addon actually loads. */
export function windowsProcessTreeAddonPath(packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR) {
return join(packageDir, 'build', 'Release', 'windows_process_tree.node')
diff --git a/config/scripts/windows-process-tree-gyp-rebuild.test.mjs b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs
index 73756af16591..620deba58c4e 100644
--- a/config/scripts/windows-process-tree-gyp-rebuild.test.mjs
+++ b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs
@@ -16,6 +16,7 @@ import {
assertWindowsProcessTreeRuntimeCreationTime,
inspectWindowsProcessTreeAddon,
nodeGypRebuildInvocation,
+ nodeGypRebuildTimeoutMs,
stageWindowsProcessTreeNodeAddonApiHeaders,
WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS,
WINDOWS_PROCESS_TREE_PACKAGE_DIR
@@ -54,6 +55,33 @@ describe('windows-process-tree node-gyp rebuild', () => {
})
})
+ it('allows cold setup and compilation only for node-pty on a Windows ARM CI host', () => {
+ expect(
+ nodeGypRebuildTimeoutMs('node-pty', { platform: 'win32', arch: 'arm64', ci: 'true' })
+ ).toBe(600_000)
+ })
+
+ it.each([
+ { moduleName: 'node-pty', platform: 'win32', arch: 'x64', ci: 'true' },
+ { moduleName: 'node-pty', platform: 'linux', arch: 'arm64', ci: 'true' },
+ { moduleName: 'node-pty', platform: 'darwin', arch: 'arm64', ci: 'true' },
+ { moduleName: 'node-pty', platform: 'win32', arch: 'arm64', ci: '' },
+ { moduleName: 'node-pty', platform: 'win32', arch: 'arm64', ci: 'false' },
+ { moduleName: 'node-pty', platform: 'win32', arch: 'arm64', ci: '1' },
+ { moduleName: '@orca/windows-registry', platform: 'win32', arch: 'arm64', ci: 'true' },
+ { moduleName: '@vscode/windows-process-tree', platform: 'win32', arch: 'arm64', ci: 'true' }
+ ])('keeps the five-minute bound for $moduleName on $platform/$arch with CI=$ci', (host) => {
+ expect(nodeGypRebuildTimeoutMs(host.moduleName, host)).toBe(300_000)
+ })
+
+ it('uses the execution host rather than an ARM cross-compilation target', () => {
+ const { args } = nodeGypRebuildInvocation('arm64', import.meta.dirname)
+ expect(args).toContain('--arch=arm64')
+ expect(
+ nodeGypRebuildTimeoutMs('node-pty', { platform: 'win32', arch: 'x64', ci: 'true' })
+ ).toBe(300_000)
+ })
+
it('copies node-addon-api headers into the patched include dir', () => {
const packageDir = mkdtempSync(join(tmpdir(), 'orca-windows-process-tree-headers-'))
try {