fix(orcad): orca serve owns its Claude and Codex accounts again (#29209)

* fix(orcad): orca serve owns its Claude and Codex accounts again (#28962)

orcad never wired account services, so every accounts.* RPC failed once
orca serve moved onto orcad. Build the same account services the desktop
builds (now in provider-account-services.ts), made Electron-free, and
install them in orcad except on a WSL host, whose desktop owns its accounts.

* test(orcad): keep account-services test off the real userData when XDG_CONFIG_HOME is set

* fix(orcad): structured Codex chats and session discovery follow this host's managed accounts

Create orcad's account services before the runtime so its structured Codex launch/read resolvers and catalog-probe prep are constructor deps, as on the desktop. A client-sent account home still wins, and a WSL host keeps them unset so the desktop-pushed CODEX_HOME applies. AI Vault session discovery now includes this host's managed Codex homes.

* fix(orcad): automatic Codex resumes follow the account home their session was recorded in

Share the desktop's resume launch prep as a factory and wire it into orcad's PTY registration and AI Vault resume, so a pane restored after an account switch resumes from its origin home. A client-sent account home keeps resuming from itself.

---------

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
OrcaWin
2026-10-15 17:38:52 -07:00
committed by GitHub
co-authored by Neil
parent bd6f586f5b
commit 16eaceb6a3
57 changed files with 954 additions and 429 deletions
+4 -1
View File
@@ -15,7 +15,10 @@ export const ORCAD_CHILD_ENTRY_POINTS = {
backup: 'src/main/persistence/profile-state/profile-state-backup-worker-entry.ts',
foreignSqliteReader: 'src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts',
portScanCommandWorker: 'src/main/ports/port-scan-command-worker-entry.ts',
sessionScanner: 'src/main/ai-vault/session-scanner-service-entry.ts'
sessionScanner: 'src/main/ai-vault/session-scanner-service-entry.ts',
workerThreadChildProcess:
'src/main/worker-thread-child-process/worker-thread-child-process-entry.ts',
claudeProfileSetup: 'src/main/claude-accounts/claude-profile-setup-worker-entry.ts'
}
/** Each child ships flat beside orcad.js under its source basename; the runtime resolvers look there. */
@@ -1,6 +1,6 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { app } from 'electron'
import { getAppEnvironment } from '../../shared/app-environment'
import { quotePosixShell } from '../../shared/wsl-login-shell-command'
import { parseWslUncPath } from '../../shared/wsl-paths'
import type { CodexManagedAccount } from '../../shared/managed-account-types'
@@ -15,7 +15,7 @@ export class CodexManagedHomePath {
constructor(private readonly validateWslPath: (distro: string, script: string) => string) {}
getRoot(): string {
const root = join(app.getPath('userData'), 'codex-accounts')
const root = join(getAppEnvironment().getPath('userData'), 'codex-accounts')
mkdirSync(root, { recursive: true })
return root
}
@@ -23,7 +23,7 @@ export class CodexManagedHomePath {
assertHostOwnership(candidatePath: string, expectedAccountId: string): string {
return assertOwnedHostCodexManagedHomePath({
candidatePath,
managedAccountsRoot: join(app.getPath('userData'), 'codex-accounts'),
managedAccountsRoot: join(getAppEnvironment().getPath('userData'), 'codex-accounts'),
systemCodexHomePath: getSystemCodexHomePath(),
expectedAccountId
})
@@ -4,6 +4,7 @@
import type * as NodeOs from 'node:os'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import type { AgentSessionAccountHome } from '../../shared/agent-session-account-home'
import { createSettings } from './runtime-home-settings-test-fixtures'
import {
@@ -23,6 +24,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof NodeOs>('node:os')
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import type * as NodeOs from 'node:os'
@@ -25,6 +26,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof NodeOs>('node:os')
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import {
existsSync,
mkdirSync,
@@ -29,6 +30,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { createSettings } from './runtime-home-settings-test-fixtures'
@@ -24,6 +25,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { createSettings } from './runtime-home-settings-test-fixtures'
@@ -25,6 +26,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -10,6 +11,7 @@ const testState = { userData: '', home: '' }
const previousEnv: Record<string, string | undefined> = {}
vi.mock('electron', () => ({ app: { getPath: () => testState.userData } }))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userData }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof NodeOs>('node:os')
return { ...actual, homedir: () => testState.home }
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { createSettings } from './runtime-home-settings-test-fixtures'
@@ -21,6 +22,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { realpathSync, writeFileSync } from 'node:fs'
import type * as NodeFs from 'node:fs'
import { join } from 'node:path'
@@ -70,6 +71,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { createSettings } from './runtime-home-settings-test-fixtures'
@@ -30,6 +31,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { realpathSync, writeFileSync } from 'node:fs'
import type * as NodeFs from 'node:fs'
import { join } from 'node:path'
@@ -76,6 +77,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { createSettings } from './runtime-home-settings-test-fixtures'
@@ -27,6 +28,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -7,7 +7,7 @@ import {
symlinkSync,
unlinkSync
} from 'node:fs'
import { app } from 'electron'
import { getAppEnvironment } from '../../shared/app-environment'
import { dirname, isAbsolute, join, resolve } from 'node:path'
import { getOrcaManagedCodexHomePath, getOrcaUserDataPath } from '../codex/codex-home-paths'
import type { CodexMirroredHomeStatus } from './runtime-home-service-types'
@@ -65,7 +65,7 @@ export abstract class CodexRuntimeHomePaths extends CodexRuntimeHomeState {
}
protected getRuntimeMetadataDir(): string {
const metadataDir = join(app.getPath('userData'), 'codex-runtime-home')
const metadataDir = join(getAppEnvironment().getPath('userData'), 'codex-runtime-home')
mkdirSync(metadataDir, { recursive: true })
return metadataDir
}
@@ -83,7 +83,7 @@ export abstract class CodexRuntimeHomePaths extends CodexRuntimeHomeState {
}
protected getManagedAccountsRoot(): string {
return join(app.getPath('userData'), 'codex-accounts')
return join(getAppEnvironment().getPath('userData'), 'codex-accounts')
}
protected repointLegacyActiveHomePointer(activeHomePath: string, runtimeHomePath: string): void {
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -18,6 +19,7 @@ vi.mock('../codex/codex-hook-trust-derivation', async (importOriginal) =>
)
)
vi.mock('electron', () => ({ app: { getPath: () => testState.userData } }))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userData }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof NodeOs>('node:os')
return { ...actual, homedir: () => testState.home }
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { chmodSync, existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { createSettings } from './runtime-home-settings-test-fixtures'
@@ -19,6 +20,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import {
chmodSync,
existsSync,
@@ -30,6 +31,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { EventEmitter } from 'node:events'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
@@ -28,6 +29,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import {
@@ -17,6 +18,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { existsSync } from 'node:fs'
import {
createManagedHome,
@@ -15,6 +16,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -6,7 +6,8 @@
* an empty lane, and the decision is made from the value captured *before* the
* OAuth await because the runtime-home poll runs outside the mutation queue.
*/
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { EventEmitter } from 'node:events'
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
@@ -28,6 +29,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import type { RateLimitState } from '../../shared/rate-limit-types'
import { buildCodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope'
import {
@@ -20,6 +21,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { join } from 'node:path'
import { realpathSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import type * as NodeFs from 'node:fs'
@@ -75,6 +76,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { existsSync } from 'node:fs'
import { buildCodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope'
import {
@@ -20,6 +21,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import {
@@ -17,6 +18,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
+3 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { mkdirSync, readFileSync, statSync, utimesSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import {
@@ -23,6 +24,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { realpathSync, writeFileSync } from 'node:fs'
import type * as NodeFs from 'node:fs'
import { join } from 'node:path'
@@ -92,6 +93,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { EventEmitter } from 'node:events'
import type * as NodeFs from 'node:fs'
import type * as HostCodexManagedHomeOwnership from './host-codex-managed-home-ownership'
@@ -27,6 +28,7 @@ vi.mock('electron', () => ({
getPath: () => testState.userDataDir
}
}))
beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -0,0 +1,69 @@
import type { CodexHomeLaunchContext } from '../ipc/pty/host-env/types'
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
import type { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { codexHookService } from './hook-service'
import { getDefaultWslDistro } from '../wsl'
import { isAgentStatusHooksEnabledForAgent } from '../agent-hooks/managed-agent-hook-controls'
import { reconcileCodexHooksForLaunch } from './codex-hook-reconcile'
/** Codex launch prep against a host's runtime-home service; the desktop and orcad share it. */
export function createCodexRuntimeHomeLaunchPreparation(deps: {
getRuntimeHome: () => CodexRuntimeHomeService | null
getSettings: () => GlobalSettings | undefined
}) {
return async (
target?: CodexAccountSelectionTarget,
launchEnv?: NodeJS.ProcessEnv,
launchContext?: CodexHomeLaunchContext
): Promise<string | null> => {
const runtimeHome = deps.getRuntimeHome()
if (!runtimeHome) {
throw new Error('Codex runtime home service is not initialized')
}
// Why: a ManagedCodexHomeTemporarilyUnavailableError must escape uncaught —
// the fallbacks below all key off `null`, which means "system default", so
// swallowing the refusal would launch the wrong account (#STA-4422).
const runtimeHomePath = await runtimeHome.prepareForCodexLaunchAsync(target, launchEnv, {
unavailableManagedHomePath: launchContext?.unavailableManagedHomePath
})
const launchesCodex = launchContext?.launchesCodex === true
if (runtimeHomePath === null && target?.runtime !== 'wsl') {
// Why only a Codex launch waits: the pane spawn already schedules the reconcile, which
// writes only on a change; plain terminals and structured launches never wait on it.
if (launchesCodex) {
await reconcileCodexHooksForLaunch()
}
return null
}
const hookTarget =
target?.runtime === 'wsl'
? { runtime: 'wsl' as const, wslDistro: target.wslDistro?.trim() || getDefaultWslDistro() }
: target
const isHooksEnabled = (): boolean =>
isAgentStatusHooksEnabledForAgent(deps.getSettings(), 'codex')
const hooksEnabled = isHooksEnabled()
try {
// Why: honor the persisted off switch so post-startup launches can't reinstall removed hooks.
const status = await codexHookService.prepareRuntimeHomeForLaunch(
runtimeHomePath,
hookTarget,
isHooksEnabled,
launchesCodex
)
if (status.state === 'error') {
console.warn(
`[codex-hook-service] failed to ${hooksEnabled ? 'refresh' : 'refresh user'} runtime hooks before launch`,
status.detail
)
}
} catch (error) {
// Why: hook install is best-effort launch prep; a malformed hooks file must not block Codex from starting.
console.warn(
`[codex-hook-service] failed to ${hooksEnabled ? 'refresh' : 'refresh user'} runtime hooks before launch`,
error
)
}
return runtimeHomePath
}
}
@@ -0,0 +1,141 @@
import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume'
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
import {
trustedCodexResumeHomes,
type CodexSessionResumePreparation
} from './codex-session-resume-home'
import { prepareCodexSessionResume } from './codex-session-resume-preparation'
import {
prepareCodexAccountRestartResume,
prepareLegacySharedCodexSessionResume
} from './codex-legacy-session-resume'
import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership'
import { codexHookService } from './hook-service'
import { reconcileCodexHooksForLaunch } from './codex-hook-reconcile'
import { ensureCodexDaemonSocketGuard } from './codex-config-mirror'
import { isAgentStatusHooksEnabledForAgent } from '../agent-hooks/managed-agent-hook-controls'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import type { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service'
import type { GlobalSettings } from '../../shared/global-settings-types'
/** Automatic Codex resume prep against a host's runtime-home service; the desktop and orcad share it. */
export function createCodexSessionResumeLaunchPreparation(deps: {
getRuntimeHome: () => CodexRuntimeHomeService | null
getSettings: () => GlobalSettings | undefined
}) {
/** Hook repair and the daemon-socket guard for a pane that pins `home` as its CODEX_HOME rather
* than launching in the selected account's home, which the pane spawn prepares itself. */
async function prepareCodexPinnedLaunchHome(
home: string,
systemHomePath: string = getSystemCodexHomePath()
): Promise<void> {
const isSystemHome =
normalizeRuntimePathForComparison(home) === normalizeRuntimePathForComparison(systemHomePath)
const isHooksEnabled = (): boolean =>
isAgentStatusHooksEnabledForAgent(deps.getSettings(), 'codex')
try {
if (isSystemHome) {
// Why bounded: the resume waits only briefly for a reconcile; it runs on ~/.codex whatever the selection.
await reconcileCodexHooksForLaunch()
} else if (isHooksEnabled()) {
await codexHookService.installForLaunchPrep(home, true, isHooksEnabled)
} else {
await codexHookService.refreshRuntimeUserHooksForLaunchPrep(home)
}
} catch (error) {
// Why: hook repair is best-effort; it must not stop the pane from launching in this home.
console.warn('[codex-hook-service] failed to prepare automatic resume home:', error)
}
if (!isSystemHome) {
// Why: this pins the pane's CODEX_HOME, and hook repair above can skip or fail before its config mirror applies the daemon guard.
ensureCodexDaemonSocketGuard(home)
}
}
async function prepareCodexSessionResumeForLaunch(args: {
providerSession: AgentProviderSessionMetadata
target: CodexAccountSelectionTarget
launchEnv?: NodeJS.ProcessEnv
useSelectedAccount?: boolean
}): Promise<CodexSessionResumePreparation | null> {
const runtimeHome = deps.getRuntimeHome()
if (args.target.runtime === 'wsl' || !runtimeHome || !deps.getSettings()) {
return null
}
const systemHomePath = getSystemCodexHomePath()
const trustedHomes = trustedCodexResumeHomes(runtimeHome, systemHomePath)
// Why: resolved eagerly, once, before any ranking or provenance match. The
// marker read used to be deferred into the ranking thunk so a
// provenance-present resume never paid for it, but that optimisation let an
// unreadable selected home reach the PTY as "no selection": the provenance
// branch simply omits the account from `trustedHomes` and another account's
// readable alias wins. A throw here refuses the whole resume instead
// (#STA-4422).
const selectedAccountCodexHome = runtimeHome.resolveSelectedHostAccountCodexHomePathForResume()
// Why: a `fresh` outcome must skip migration and hook repair entirely — there is
// no verified origin home to prepare, so the PTY layer drops the resume argv (#10793).
const preparation = await prepareCodexSessionResume({
sessionId: args.providerSession.id,
transcriptPath: args.providerSession.transcriptPath,
trustedCodexHomes: trustedHomes,
// Why: the legacy id rescan's winning home becomes this pane's CODEX_HOME, i.e. its account;
// rank it by the current selection so settings insertion order can never decide the account.
getSelectedAccountCodexHome: () => selectedAccountCodexHome,
systemCodexHomePath: systemHomePath,
// Why: the mirror winning is what triggers the migration into ~/.codex below, so it must
// outrank the path-sorted account homes or a system-default selection resumes as an account.
sharedRuntimeCodexHomePath: getOrcaManagedCodexHomePath(),
resolveVerifiedResumeHome: async (sessionSource) => {
let migrated = { useRealCodexHome: false }
try {
migrated = await prepareLegacySharedCodexSessionResume(
{
agent: 'codex',
executionHostId: 'local',
filePath: sessionSource.transcriptPath,
codexHome: sessionSource.homePath
},
{
isHostSystemDefaultRealHomeSelected: () =>
runtimeHome.isHostSystemDefaultRealHomeSelected(),
systemCodexHomePath: systemHomePath
}
)
} catch (error) {
// A credential-read refusal must never fall back to the old account.
if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) {
throw error
}
// Why: migration is a compatibility repair; its failure must not prevent the PTY from resuming from its trusted origin home.
console.warn(
'[codex-session-resume] Legacy rollout migration failed; using origin home:',
error
)
}
const resumeHome = args.useSelectedAccount
? await prepareCodexAccountRestartResume({
sourceHome: sessionSource.homePath,
transcriptPath: sessionSource.transcriptPath,
targetHome: selectedAccountCodexHome ?? systemHomePath,
systemCodexHomePath: systemHomePath
})
: migrated.useRealCodexHome
? systemHomePath
: sessionSource.homePath
await prepareCodexPinnedLaunchHome(resumeHome, systemHomePath)
return resumeHome
}
})
return preparation.outcome === 'resume'
? {
...preparation,
reconcileSharedRuntimeAuth:
normalizeRuntimePathForComparison(preparation.codexHomePath) ===
normalizeRuntimePathForComparison(getOrcaManagedCodexHomePath())
}
: preparation
}
return { prepareCodexSessionResumeForLaunch, prepareCodexPinnedLaunchHome }
}
@@ -1,4 +1,4 @@
import { guardedSafeStorage as safeStorage } from '../host/safe-storage-availability'
import { getSecretStore } from '../../shared/secret-store'
import { existsSync, readFileSync, rmSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
@@ -73,10 +73,10 @@ export function createEncryptedApiKeyFileStore({
if (envelope.kind === 'plaintext') {
return envelope.payload.toString('utf8')
}
if (!safeStorage.isEncryptionAvailable()) {
if (!getSecretStore().isEncryptionAvailable()) {
throw new Error(`${providerLabel} API key could not be decrypted`)
}
return safeStorage.decryptString(envelope.payload)
return getSecretStore().decryptString(envelope.payload)
}
function has(): boolean {
@@ -115,10 +115,10 @@ export function createEncryptedApiKeyFileStore({
if (!trimmed) {
throw new Error(`${providerLabel} API key is required`)
}
if (safeStorage.isEncryptionAvailable()) {
if (getSecretStore().isEncryptionAvailable()) {
writeSecureFile(
getApiKeyPath(),
encodeApiKeyEnvelope('encrypted', safeStorage.encryptString(trimmed)),
encodeApiKeyEnvelope('encrypted', getSecretStore().encryptString(trimmed)),
{ durable: true }
)
cachedApiKey = trimmed
+2 -1
View File
@@ -46,5 +46,6 @@ async function fetchFromDefaultSession(url: string, init?: RequestInit): Promise
*/
export const electronHttpClient: MainHttpClient = {
fetch: fetchFromDefaultSession,
proxySession: () => session.defaultSession
proxySession: () => session.defaultSession,
partitionSession: (partition) => session.fromPartition(partition)
}
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setSecretStore } from '../../shared/secret-store'
import type * as MiniMaxApiKeyStore from './minimax-api-key-store'
const safeStorageMock = vi.hoisted(() => ({
@@ -7,11 +8,7 @@ const safeStorageMock = vi.hoisted(() => ({
decryptString: vi.fn((value: Buffer) => value.toString('utf8'))
}))
const electronMock = vi.hoisted(() => ({
safeStorage: safeStorageMock
}))
vi.mock('electron', () => electronMock)
beforeEach(() => setSecretStore({ ...safeStorageMock, describeProtectionGap: () => null }))
const existsSyncMock = vi.fn()
const readFileSyncMock = vi.fn()
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as MiniMaxCookieStore from './minimax-cookie-store'
import { setSecretStore } from '../../shared/secret-store'
const safeStorageMock = vi.hoisted(() => ({
isEncryptionAvailable: vi.fn(() => true),
@@ -7,12 +8,6 @@ const safeStorageMock = vi.hoisted(() => ({
decryptString: vi.fn((value: Buffer) => value.toString('utf8'))
}))
const electronMock = vi.hoisted(() => ({
safeStorage: safeStorageMock
}))
vi.mock('electron', () => electronMock)
const existsSyncMock = vi.fn()
const readFileSyncMock = vi.fn()
const rmSyncMock = vi.fn()
@@ -60,6 +55,7 @@ describe('minimax-cookie-store', () => {
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value))
safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf8'))
setSecretStore({ ...safeStorageMock, describeProtectionGap: () => null })
})
afterEach(() => {
+7 -7
View File
@@ -1,4 +1,4 @@
import { guardedSafeStorage as safeStorage } from '../host/safe-storage-availability'
import { getSecretStore } from '../../shared/secret-store'
import { existsSync, readFileSync, rmSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
@@ -72,16 +72,16 @@ function readEnvelope(envelope: MiniMaxCookieEnvelope): string {
if (envelope.kind === 'plaintext') {
return envelope.payload.toString('utf8')
}
if (!safeStorage.isEncryptionAvailable()) {
if (!getSecretStore().isEncryptionAvailable()) {
throw new Error('MiniMax session cookie could not be decrypted')
}
return safeStorage.decryptString(envelope.payload)
return getSecretStore().decryptString(envelope.payload)
}
function readLegacyCookie(raw: Buffer): string {
if (safeStorage.isEncryptionAvailable()) {
if (getSecretStore().isEncryptionAvailable()) {
try {
return safeStorage.decryptString(raw)
return getSecretStore().decryptString(raw)
} catch {
const plaintext = raw.toString('utf8')
if (looksLikeCookieHeader(plaintext)) {
@@ -142,10 +142,10 @@ export function saveMiniMaxSessionCookie(cookie: string): void {
if (!trimmed) {
throw new Error('MiniMax session cookie is required')
}
if (safeStorage.isEncryptionAvailable()) {
if (getSecretStore().isEncryptionAvailable()) {
writeSecureFile(
getMiniMaxCookiePath(),
encodeCookieEnvelope('encrypted', safeStorage.encryptString(trimmed))
encodeCookieEnvelope('encrypted', getSecretStore().encryptString(trimmed))
)
cachedMiniMaxCookie = trimmed
return
+13
View File
@@ -23,6 +23,8 @@ export type MainHttpClient = {
fetch(url: string, init?: RequestInit): Promise<Response>
/** The Chromium session whose proxy state applies, or null on a host without one. */
proxySession(): Session | null
/** An isolated Chromium cookie jar; absent on a host without Chromium. */
partitionSession?(partition: string): Session
}
const nodeHttpClient: MainHttpClient = {
@@ -39,3 +41,14 @@ export function setMainHttpClient(client: MainHttpClient | null): void {
export function getMainHttpClient(): MainHttpClient {
return current
}
/** A provider's isolated cookie jar; throws where no Chromium session exists (orcad). */
export function requirePartitionSession(partition: string, providerLabel: string): Session {
const client = getMainHttpClient()
if (!client.partitionSession) {
throw new Error(
`${providerLabel} usage from a session cookie needs the Orca desktop app and is not available on this server`
)
}
return client.partitionSession(partition)
}
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setSecretStore } from '../../shared/secret-store'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import type * as NodeFs from 'node:fs'
@@ -25,13 +26,14 @@ vi.mock('node:fs', async (importOriginal) => {
}
}
})
vi.mock('electron', () => ({
safeStorage: {
beforeEach(() =>
setSecretStore({
isEncryptionAvailable: () => true,
encryptString: (key: string) => Buffer.from(`encrypted:${key}`),
decryptString: (bytes: Buffer) => bytes.toString().slice('encrypted:'.length)
}
}))
decryptString: (bytes: Buffer) => bytes.toString().slice('encrypted:'.length),
describeProtectionGap: () => null
})
)
beforeEach(() => {
home.readError = null
@@ -0,0 +1,168 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Store } from '../persistence/loading-store/store'
import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority'
import { OrcaRuntimeService } from '../runtime/orca-runtime'
import { eraseRpcMethods, isStreamingMethod } from '../runtime/rpc/core'
import { ACCOUNT_METHODS } from '../runtime/rpc/methods/accounts'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { createOrcadAccountServices } from './orcad-account-services'
import type { OrcadRuntimeCleanup } from './orcad-runtime-lifetime'
const directories: string[] = []
const stores: Store[] = []
let cleanups: OrcadRuntimeCleanup[] = []
function tempDirectory(): string {
const directory = mkdtempSync(join(tmpdir(), 'orcad-account-services-'))
directories.push(directory)
return directory
}
beforeEach(() => {
// Why a temp home and userData: the account services resolve ~/.codex, ~/.claude and their
// managed homes from these, and a test must never touch the developer's own.
const home = tempDirectory()
const userData = tempDirectory()
vi.stubEnv('HOME', home)
vi.stubEnv('USERPROFILE', home)
// Why: the Codex runtime home reads this (as the host seeds it); otherwise XDG_CONFIG_HOME wins on CI.
vi.stubEnv('ORCA_USER_DATA_PATH', userData)
installFakeAppEnvironment({
getPath: (name) => (name === 'home' ? home : name === 'temp' ? tmpdir() : userData)
})
})
afterEach(async () => {
for (const cleanup of cleanups.toReversed()) {
await cleanup({ failed: false })
}
cleanups = []
for (const store of stores.splice(0)) {
store.freezeWrites()
}
vi.unstubAllEnvs()
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
function createRuntime(): { runtime: OrcaRuntimeService; store: Store } {
const directory = tempDirectory()
const store = new Store({
dataFile: join(directory, 'orca-data.json'),
profileStateAuthority: new ProfileStateSqliteAuthority(
join(directory, 'profile-state.db'),
'orcad-account-services'
)
})
stores.push(store)
return { runtime: new OrcaRuntimeService(store), store }
}
const CLIENT_HOME = '/home/user/.local/share/orca/codex-accounts/client-account/home'
function create(store: Store, isWslHost: boolean): ReturnType<typeof createOrcadAccountServices> {
// Stands in for orcadCodexAccountHomes: only CLIENT_HOME is a client-owned account home.
const clientHome = (launchEnv?: NodeJS.ProcessEnv): string | null =>
launchEnv?.CODEX_HOME === CLIENT_HOME ? CLIENT_HOME : null
return createOrcadAccountServices({
store,
setClaudeStatusLineListener: vi.fn(),
registerCleanup: (cleanup) => cleanups.push(cleanup),
clientCodexHomes: {
selectLaunchHome: async (_target, launchEnv) => clientHome(launchEnv),
readLaunchHome: clientHome
},
isWslHost: () => isWslHost
})
}
function install(
runtime: OrcaRuntimeService,
store: Store,
isWslHost: boolean
): ReturnType<typeof createOrcadAccountServices> {
const services = create(store, isWslHost)
services?.attachToRuntime(runtime)
return services
}
async function call(runtime: OrcaRuntimeService, name: string, params: unknown): Promise<unknown> {
const found = eraseRpcMethods(ACCOUNT_METHODS).find((candidate) => candidate.name === name)
if (!found || isStreamingMethod(found)) {
throw new Error(`Missing request method ${name}`)
}
return found.handler(found.params ? found.params.parse(params) : params, { runtime })
}
describe('orcad account services', () => {
it('serves accounts.* from this host', async () => {
const { runtime, store } = createRuntime()
expect(install(runtime, store, false)).not.toBeNull()
await expect(call(runtime, 'accounts.list', { refreshUsage: false })).resolves.toMatchObject({
claude: { accounts: [], activeAccountId: null },
codex: { accounts: [], activeAccountId: null },
rateLimits: expect.any(Object)
})
await expect(
call(runtime, 'accounts.selectClaude', { accountId: null })
).resolves.toMatchObject({ activeAccountId: null })
await expect(call(runtime, 'accounts.selectCodex', { accountId: null })).resolves.toMatchObject(
{
activeAccountId: null
}
)
})
it("resolves structured Codex launches to a client-sent home, else this host's selection", async () => {
const { store } = createRuntime()
const services = create(store, false)
if (!services) {
throw new Error('expected account services on a non-WSL host')
}
const { prepareCodexStructuredLaunch, resolveCodexStructuredLaunchHome } = services.runtimeDeps
const clientEnv = { CODEX_HOME: CLIENT_HOME }
await expect(prepareCodexStructuredLaunch({ launchEnv: clientEnv })).resolves.toBe(CLIENT_HOME)
expect(resolveCodexStructuredLaunchHome({ launchEnv: clientEnv })).toBe(CLIENT_HOME)
// The system default selected here: null is the real ~/.codex, not an ambient CODEX_HOME.
await expect(prepareCodexStructuredLaunch({ launchEnv: {} })).resolves.toBeNull()
expect(resolveCodexStructuredLaunchHome({ launchEnv: {} })).toBeNull()
expect(services.listCodexSessionHomes()).toEqual(expect.any(Array))
})
it('prepares automatic Codex resumes on this host, but not for a client-sent home', async () => {
const { store } = createRuntime()
const services = create(store, false)
if (!services) {
throw new Error('expected account services on a non-WSL host')
}
const resume = (launchEnv: NodeJS.ProcessEnv) =>
services.prepareCodexSessionResume({
providerSession: { key: 'session_id', id: '019a0000-0000-7000-8000-000000000000' },
target: { runtime: 'host' },
launchEnv
})
await expect(resume({ CODEX_HOME: CLIENT_HOME })).resolves.toBeNull()
// No trusted home records the session, so the pane starts fresh instead of resuming blind.
await expect(resume({})).resolves.toMatchObject({ outcome: 'fresh' })
})
it('leaves a WSL host to the desktop that owns its accounts', async () => {
const { runtime, store } = createRuntime()
expect(install(runtime, store, true)).toBeNull()
await expect(call(runtime, 'accounts.list', { refreshUsage: false })).rejects.toThrow(
'Account services are not configured on this runtime'
)
// The desktop's push must still land.
expect(() =>
runtime.setHostAccountLaunchEnv({ wslDistro: 'Ubuntu', claude: {}, terminal: {} })
).not.toThrow()
})
})
+133
View File
@@ -0,0 +1,133 @@
/**
* The Claude/Codex accounts this server owns, so `accounts.*` and the launches it starts use the
* account selected here, as desktop `orca serve` did (#28962).
*/
import type { Store } from '../persistence'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type {
GetSelectedCodexHomePath,
PrepareClaudeAuth,
PrepareCodexSessionResume
} from '../ipc/pty/host-env/types'
import type {
AiVaultPrepareSessionResumeArgs,
AiVaultPrepareSessionResumeResult
} from '../../shared/ai-vault-resume-preparation'
import type { ClaudeStatusLineRateLimits } from '../../shared/claude-statusline-rate-limits'
import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults'
import { createProviderAccountServices } from '../provider-account-services'
import { createCodexRuntimeHomeLaunchPreparation } from '../codex/codex-runtime-home-launch-preparation'
import { createCodexSessionResumeLaunchPreparation } from '../codex/codex-session-resume-launch-preparation'
import { prepareCodexAiVaultSessionResume } from '../codex/codex-ai-vault-session-resume'
import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home'
import { resolveHostAgentBaseEnvironment } from '../runtime/structured-agent-shell-environment'
import { isWslHostOrcadProcess } from '../ipc/wsl-guest-windows-drive-path'
import type { OrcadRuntimeCleanup } from './orcad-runtime-lifetime'
type StructuredLaunchInput = { launchEnv: NodeJS.ProcessEnv }
export type OrcadAccountServices = {
prepareCodexHome: ReturnType<typeof createCodexRuntimeHomeLaunchPreparation>
prepareClaudeAuth: PrepareClaudeAuth
/** Pins an automatic resume to the account home its session was recorded in. */
prepareCodexSessionResume: PrepareCodexSessionResume
/** Runtime constructor deps: structured chats and catalog reads follow this host's Codex selection. */
runtimeDeps: {
prepareCodexStructuredLaunch: (input: StructuredLaunchInput) => Promise<string | null>
resolveCodexStructuredLaunchHome: (input: StructuredLaunchInput) => string | null
prepareCodexCatalogProbeHome: (homePath: string) => void
prepareAiVaultSessionResume: (
args: AiVaultPrepareSessionResumeArgs
) => Promise<AiVaultPrepareSessionResumeResult>
}
/** The managed Codex homes this host's launches write sessions into. */
listCodexSessionHomes: () => string[]
attachToRuntime: (
runtime: Pick<
OrcaRuntimeService,
'setAccountServices' | 'setCommitMessageAgentEnvironmentResolvers'
>
) => void
}
/** Null on a WSL host, whose runtime must keep the desktop-pushed CODEX_HOME (no structured resolvers). */
export function createOrcadAccountServices(args: {
store: Store
setClaudeStatusLineListener: (
listener: ((event: ClaudeStatusLineRateLimits) => void) | null
) => void
registerCleanup: (cleanup: OrcadRuntimeCleanup) => void
/** A client-sent account home (launch env CODEX_HOME) wins over this host's selection, as for PTYs. */
clientCodexHomes: {
selectLaunchHome: GetSelectedCodexHomePath
readLaunchHome: (launchEnv?: NodeJS.ProcessEnv) => string | null
}
isWslHost?: () => boolean
}): OrcadAccountServices | null {
// Why: the Windows desktop owns a WSL host's accounts and pushes them (accounts.setHostLaunchEnv),
// which a host with services of its own refuses.
if ((args.isWslHost ?? isWslHostOrcadProcess)()) {
return null
}
const { store, clientCodexHomes } = args
const services = createProviderAccountServices(store, {
setClaudeStatusLineListener: args.setClaudeStatusLineListener
})
// Why no start(): like headless desktop serve, usage refreshes on demand (accounts.list, subscribe).
args.registerCleanup(() => {
services.rateLimits.stop()
args.setClaudeStatusLineListener(null)
})
const { codexRuntimeHome } = services
const prepareCodexHome = createCodexRuntimeHomeLaunchPreparation({
getRuntimeHome: () => codexRuntimeHome,
getSettings: () => store.getSettings()
})
const prepareClaudeAuth: PrepareClaudeAuth = (target) =>
services.claudeRuntimeAuth.prepareForClaudeLaunch(target)
const resumeLaunch = createCodexSessionResumeLaunchPreparation({
getRuntimeHome: () => codexRuntimeHome,
getSettings: () => store.getSettings()
})
return {
prepareCodexHome,
prepareClaudeAuth,
// Why skip a client-sent home: its sessions live there, outside this host's trusted homes.
prepareCodexSessionResume: async (resumeArgs) =>
clientCodexHomes.readLaunchHome(resumeArgs.launchEnv)
? null
: resumeLaunch.prepareCodexSessionResumeForLaunch(resumeArgs),
runtimeDeps: {
prepareCodexStructuredLaunch: async ({ launchEnv }) =>
(await clientCodexHomes.selectLaunchHome(undefined, launchEnv)) ??
prepareCodexHome(undefined, launchEnv),
resolveCodexStructuredLaunchHome: ({ launchEnv }) =>
clientCodexHomes.readLaunchHome(launchEnv) ??
codexRuntimeHome.resolveHostCodexHomePathForLaunchReadOnly(launchEnv),
prepareCodexCatalogProbeHome: (homePath) =>
codexRuntimeHome.prepareHostCodexHomeForReadOnlyAppServer(
homePath,
resolveTuiAgentLaunchEnv('codex', store.getSettings().agentDefaultEnv)
),
prepareAiVaultSessionResume: (resumeArgs) =>
prepareCodexAiVaultSessionResume(resumeArgs, {
runtimeHome: codexRuntimeHome,
systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()),
preparePinnedLaunchHome: (home) => resumeLaunch.prepareCodexPinnedLaunchHome(home)
})
},
listCodexSessionHomes: () => codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery(),
attachToRuntime: (runtime) => {
runtime.setAccountServices({
claudeAccounts: services.claudeAccounts,
codexAccounts: services.codexAccounts,
rateLimits: services.rateLimits
})
runtime.setCommitMessageAgentEnvironmentResolvers({
prepareForCodexLaunch: prepareCodexHome,
prepareForClaudeLaunch: prepareClaudeAuth,
resolveBaseEnvironment: () => resolveHostAgentBaseEnvironment(store.getSettings())
})
}
}
}
@@ -35,6 +35,9 @@ export function orcadCodexAccountHomes(
}
return {
selectLaunchHome,
/** selectLaunchHome's home with no hook reconcile, for record-less catalog reads. */
readLaunchHome: (launchEnv?: NodeJS.ProcessEnv): string | null =>
resolveGuestCodexAccountHome(launchEnv?.CODEX_HOME),
listSessionHomes: (): readonly string[] => listGuestCodexAccountHomes()
}
}
+30 -7
View File
@@ -72,8 +72,11 @@ export async function startOrcad(options: OrcadOptions = {}): Promise<OrcadHandl
installOrcadHostAdapters()
// Why: an orcad started from an Orca terminal must not fence its own CLI children to that Orca.
stampRuntimeSourceEnv(process.env, null)
const userDataPath = resolveUserDataPath()
// Why, as on the desktop: an inherited value would aim the CLI-shared Codex home paths elsewhere.
process.env.ORCA_USER_DATA_PATH = userDataPath
const { readiness, instance, stop } = await startOrcadWithHost(
resolveUserDataPath(),
userDataPath,
(registerCleanup) => startOrcadRuntime(options, registerCleanup),
() => {
try {
@@ -196,8 +199,23 @@ async function startOrcadRuntime(
registerCleanup
)
// Why before the runtime: its structured Codex resolvers are constructor deps.
const { createOrcadAccountServices } = await import('./orcad-account-services')
const accountLaunch = createOrcadAccountServices({
store: profileStore,
setClaudeStatusLineListener: (listener) =>
agentHookServer.setClaudeStatusLineListener(listener),
registerCleanup,
clientCodexHomes: codexAccountHomes
})
const runtime = new OrcaRuntimeService(profileStore, undefined, {
getAdditionalAiVaultCodexHomePaths: codexAccountHomes.listSessionHomes,
getAdditionalAiVaultCodexHomePaths: () => [
...codexAccountHomes.listSessionHomes(),
...(accountLaunch?.listCodexSessionHomes() ?? [])
],
// Why absent on a WSL host: a resolver overrides the desktop-pushed CODEX_HOME.
...accountLaunch?.runtimeDeps,
// Why here too: orcad serves `worktree.ps` and `agentSession.*`; unwired it lists no agents.
...agentHookRuntimeDeps(agentHookServer, observedPaneIdentities, () =>
profileStore.getSettings()
@@ -224,6 +242,8 @@ async function startOrcadRuntime(
}
})
accountLaunch?.attachToRuntime(runtime)
// Why before any launch: a desktop that owns this host's accounts pushed them on an earlier run.
const { restoreOrcadHostAccountLaunchEnv } = await import('./orcad-host-account-launch-env-store')
restoreOrcadHostAccountLaunchEnv(runtime, runtimeUserDataPath)
@@ -253,14 +273,17 @@ async function startOrcadRuntime(
// same call `--serve` makes, and it threads the store through. Without the store the
// handlers install fine and every terminal.create then fails at persistence time.
//
// Claude-auth preparation is left unset: account selection is the client's, which sends the
// prepared account env. Codex only needs this host's hook put into that env's home.
// A client-sent Codex account home wins; otherwise this host's own selection applies. A WSL host
// has none: its desktop sends the prepared account env.
await registerHeadlessPtyRuntime(
runtime,
codexAccountHomes.selectLaunchHome,
async (target, launchEnv, context) =>
(await codexAccountHomes.selectLaunchHome(target, launchEnv, context)) ??
(accountLaunch ? accountLaunch.prepareCodexHome(target, launchEnv, context) : null),
() => profileStore.getSettings(),
undefined,
profileStore
accountLaunch?.prepareClaudeAuth,
profileStore,
accountLaunch?.prepareCodexSessionResume
)
// Why: same post-registration reconciliation `--serve` performs. Skipping it leaves
@@ -56,6 +56,8 @@ vi.mock('./orcad-app-paths', () => ({
resolveOrcadPath: () => state.root,
resolveUserDataPath: () => state.root
}))
// The fake store has no account settings; orcad-account-services.test.ts covers the real services.
vi.mock('./orcad-account-services', () => ({ createOrcadAccountServices: () => null }))
vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: state.browserProvider }))
vi.mock('./orcad-instance-lock', () => ({
acquireOrcadInstanceLock: () => ({
@@ -181,10 +183,13 @@ vi.mock('../runtime/push/push-gateway-client', () => ({
}))
beforeEach(() => {
// startOrcad canonicalizes this; stubbing restores the prior value after each test.
vi.stubEnv('ORCA_USER_DATA_PATH', '')
state.onSettingsChanged.mockReturnValue(state.removeSettingsListener)
})
afterEach(() => {
vi.unstubAllEnvs()
rmSync(state.root, { recursive: true, force: true })
state.profileStartupErrors.length = 0
profile.settings = {}
+179
View File
@@ -0,0 +1,179 @@
/**
* The Claude/Codex account services and the usage service they share, built the same way for
* the desktop and for orcad. Nothing here may import Electron: orcad bundles this module.
*/
import type { Store } from './persistence'
import { RateLimitService } from './rate-limits/service'
import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service'
import { CodexAccountService } from './codex-accounts/service'
import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service'
import { ClaudeAccountService } from './claude-accounts/service'
import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target'
import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target'
import { getKimiRuntimeTarget, resolveKimiHome } from './kimi/kimi-runtime-home'
import { isWslDistroRunningForBackgroundWork } from './wsl-background-distro-gate'
import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store'
import { readMiniMaxApiKey } from './minimax/minimax-api-key-store'
import { readZcodePlanApiKey } from './zcode/zcode-plan-api-key-store'
import { readOpenCodeGoApiKey } from './opencode/opencode-go-api-key-store'
import { createAccountRuntimeTargetSettingsSync } from './rate-limits/account-runtime-target-sync'
import { normalizeCodexRuntimeSelection } from './codex-accounts/runtime-selection'
import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection'
import type { ClaudeStatusLineRateLimits } from '../shared/claude-statusline-rate-limits'
export type ProviderAccountServices = {
rateLimits: RateLimitService
codexRuntimeHome: CodexRuntimeHomeService
codexAccounts: CodexAccountService
claudeRuntimeAuth: ClaudeRuntimeAuthService
claudeAccounts: ClaudeAccountService
}
export function createProviderAccountServices(
store: Store,
options: {
codexAccountLifecycle?: ConstructorParameters<typeof CodexAccountService>[3]
setClaudeStatusLineListener: (listener: (event: ClaudeStatusLineRateLimits) => void) => void
}
): ProviderAccountServices {
const rateLimits = new RateLimitService()
const codexRuntimeHome = new CodexRuntimeHomeService(store)
const codexAccounts = new CodexAccountService(
store,
rateLimits,
codexRuntimeHome,
options.codexAccountLifecycle
)
const claudeRuntimeAuth = new ClaudeRuntimeAuthService(store)
const claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth)
// Why: quota polls are background work and must not boot a stopped distro; user-driven
// resets call prepareForRateLimitFetch directly.
rateLimits.setCodexHomePathResolver(async (target) =>
target?.runtime === 'wsl' && !(await isWslDistroRunningForBackgroundWork(target.wslDistro))
? { kind: 'skip' }
: codexRuntimeHome.prepareForRateLimitFetch(target)
)
rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings()))
// Why: Kimi's CLI refreshes its OAuth token in whichever runtime it runs in, so the
// usage fetch must read the WSL-side credentials when that's the configured runtime (#12370).
rateLimits.setKimiHomeResolver(() => resolveKimiHome(getKimiRuntimeTarget(store.getSettings())))
rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings()))
const syncAccountRuntimeTargets = createAccountRuntimeTargetSettingsSync(
rateLimits,
store.getSettings()
)
store.onSettingsChanged((updates, settings) => {
// Why: auto is a live policy; retarget only providers whose settings-derived runtime changed.
void syncAccountRuntimeTargets(updates, settings).catch((error) =>
console.warn('[rate-limits] Failed to apply account runtime target:', error)
)
if ('opencodeSessionCookie' in updates || 'opencodeWorkspaceId' in updates) {
rateLimits.invalidateOpenCodeGoCredentialState()
void rateLimits.refresh().catch((error: unknown) => {
console.warn(
'[rate-limits] Failed to refresh OpenCode Go usage after a settings change:',
error
)
})
}
// Why: these three pick the MiniMax host and quota bucket, so a stale snapshot from the
// previous endpoint would otherwise sit in the status bar until the next poll.
if (
'minimaxEndpoint' in updates ||
'minimaxGroupId' in updates ||
'minimaxUsageModels' in updates
) {
rateLimits.invalidateMiniMaxCredentialState()
void rateLimits.refresh().catch((error: unknown) => {
console.warn(
'[rate-limits] Failed to refresh MiniMax usage after a settings change:',
error
)
})
}
// Why: the site picks the GLM Coding Plan quota host, so a stale snapshot from
// the previous site would otherwise sit in the status bar until the next poll.
if ('zcodePlanSite' in updates) {
rateLimits.invalidateZcodeCredentialState()
void rateLimits.refresh().catch((error: unknown) => {
console.warn(
'[rate-limits] Failed to refresh GLM Coding Plan usage after a settings change:',
error
)
})
}
})
rateLimits.setClaudeAuthPreparationResolver((target) =>
claudeRuntimeAuth.prepareForRateLimitFetch(target)
)
// Why: live Claude sessions stream usage windows through their statusLine command; feeding them here avoids OAuth usage-endpoint polling (and its 429s).
options.setClaudeStatusLineListener((event) => {
rateLimits.ingestLiveClaudeRateLimits(event)
})
rateLimits.setOpenCodeGoConfigResolver(() => {
const settings = store.getSettings()
return {
sessionCookie: settings.opencodeSessionCookie,
workspaceIdOverride: settings.opencodeWorkspaceId
}
}, readOpenCodeGoApiKey)
rateLimits.setMiniMaxConfigResolver(() => {
const settings = store.getSettings()
const apiKey = readMiniMaxApiKey() ?? ''
return {
sessionCookie: apiKey ? '' : (readMiniMaxSessionCookie() ?? ''),
groupId: settings.minimaxGroupId,
models: settings.minimaxUsageModels,
endpoint: settings.minimaxEndpoint,
apiKey
}
})
rateLimits.setZcodePlanConfigResolver(() => ({
site: store.getSettings().zcodePlanSite ?? 'zai',
apiKey: readZcodePlanApiKey() ?? ''
}))
rateLimits.setGeminiCliOAuthEnabledResolver(() => store.getSettings().geminiCliOAuthEnabled)
// Reuse the meter switch so hidden Antigravity usage does not spawn agy.
rateLimits.setAntigravityUsageEnabledResolver(() =>
store.getUI().statusBarItems.includes('antigravity')
)
rateLimits.setNetworkProxySettingsResolver(() => store.getSettings())
rateLimits.setInactiveClaudeAccountsResolver(() => {
const settings = store.getSettings()
const activeIds = new Set(
[
normalizeClaudeRuntimeSelection(settings).host,
...Object.values(normalizeClaudeRuntimeSelection(settings).wsl)
].filter(Boolean)
)
return settings.claudeManagedAccounts
.filter((account) => !activeIds.has(account.id))
.map((account) => ({
id: account.id,
managedAuthRuntime: account.managedAuthRuntime,
wslDistro: account.wslDistro,
wslLinuxAuthPath: account.wslLinuxAuthPath
}))
})
rateLimits.setInactiveCodexAccountsResolver(() => {
const settings = store.getSettings()
const activeIds = new Set(
[
normalizeCodexRuntimeSelection(settings).host,
...Object.values(normalizeCodexRuntimeSelection(settings).wsl)
].filter(Boolean)
)
return settings.codexManagedAccounts
.filter((account) => !activeIds.has(account.id))
.map((account) => ({
id: account.id,
resolveHome: () => {
const resolved = codexRuntimeHome.resolveCodexManagedAccountHomeForInactiveFetch(account)
return resolved.kind === 'ready'
? { kind: 'ready' as const, managedHomePath: resolved.homePath }
: { kind: 'skip' as const }
}
}))
})
return { rateLimits, codexRuntimeHome, codexAccounts, claudeRuntimeAuth, claudeAccounts }
}
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Session } from 'electron'
import { setMainHttpClient } from '../../network/http-client'
const { clearStorageDataMock, cookiesSetMock, fetchMock, sessionFromPartitionMock } = vi.hoisted(
@@ -15,13 +16,14 @@ const { clearStorageDataMock, cookiesSetMock, fetchMock, sessionFromPartitionMoc
}
)
vi.mock('electron', () => ({
session: { fromPartition: sessionFromPartitionMock }
}))
import { fetchMiniMaxRateLimits } from './minimax-fetcher'
setMainHttpClient({ fetch: fetchMock, proxySession: () => null })
setMainHttpClient({
fetch: fetchMock,
proxySession: () => null,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test only calls clearStorageData, cookies.set and fetch, which the fake provides.
partitionSession: sessionFromPartitionMock as unknown as (partition: string) => Session
})
const MINIMAX_URL = 'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains'
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Session } from 'electron'
import { setMainHttpClient } from '../../network/http-client'
const { clearStorageDataMock, cookiesSetMock, fetchMock, sessionFromPartitionMock } = vi.hoisted(
@@ -15,10 +16,6 @@ const { clearStorageDataMock, cookiesSetMock, fetchMock, sessionFromPartitionMoc
}
)
vi.mock('electron', () => ({
session: { fromPartition: sessionFromPartitionMock }
}))
import {
clearMiniMaxSessionCookieJar,
extractMiniMaxCookieValue,
@@ -34,7 +31,12 @@ import {
redactMiniMaxSecret
} from './minimax-request-context'
setMainHttpClient({ fetch: fetchMock, proxySession: () => null })
setMainHttpClient({
fetch: fetchMock,
proxySession: () => null,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test only calls clearStorageData, cookies.set and fetch, which the fake provides.
partitionSession: sessionFromPartitionMock as unknown as (partition: string) => Session
})
const FULL_COOKIE =
'_token=eyJh.eyJ.payload; _twpid=tw.123; minimax_group_id_v2=12345; platform_cookie_consent=3'
@@ -1,6 +1,6 @@
import { session, type Session } from 'electron'
import type { Session } from 'electron'
import type { MiniMaxEndpoint } from '../../../shared/global-settings-types'
import { getMainHttpClient } from '../../network/http-client'
import { getMainHttpClient, requirePartitionSession } from '../../network/http-client'
const MINIMAX_USAGE_PATH = '/v1/api/openplatform/coding_plan/remains'
const MINIMAX_OVERSEAS_BASE = 'https://platform.minimax.io'
@@ -149,7 +149,10 @@ export async function clearMiniMaxSessionCookieJar(): Promise<void> {
// Why: clear cookies under both origins so a user who switches endpoint
// (overseas -> CN or vice versa) does not leave stale cookies that the
// next request might pick up against the wrong host.
const miniMaxSession = session.fromPartition(MINIMAX_SESSION_PARTITION)
const miniMaxSession = getMainHttpClient().partitionSession?.(MINIMAX_SESSION_PARTITION)
if (!miniMaxSession) {
return
}
await Promise.all([
clearMiniMaxSessionCookieJarForSession(miniMaxSession, getMiniMaxOrigin('overseas')),
clearMiniMaxSessionCookieJarForSession(miniMaxSession, getMiniMaxOrigin('cn'))
@@ -163,7 +166,7 @@ export async function fetchMiniMaxWithSessionCookieJar(args: {
endpointMode: MiniMaxEndpoint
signal: AbortSignal
}): Promise<MiniMaxFetchResponse> {
const miniMaxSession = session.fromPartition(MINIMAX_SESSION_PARTITION)
const miniMaxSession = requirePartitionSession(MINIMAX_SESSION_PARTITION, 'MiniMax')
const cookiePairs = parseCookiePairs(args.cookie)
const origin = getMiniMaxOrigin(args.endpointMode)
try {
@@ -204,7 +207,7 @@ export async function fetchMiniMaxWithManualCookieHeader(args: {
endpointMode: MiniMaxEndpoint
signal: AbortSignal
}): Promise<MiniMaxFetchResponse> {
const miniMaxSession = session.fromPartition(MINIMAX_SESSION_PARTITION)
const miniMaxSession = requirePartitionSession(MINIMAX_SESSION_PARTITION, 'MiniMax')
const origin = getMiniMaxOrigin(args.endpointMode)
try {
await clearMiniMaxSessionCookieJarForSession(miniMaxSession, origin)
@@ -1,4 +1,5 @@
import { session, type Session } from 'electron'
import type { Session } from 'electron'
import { requirePartitionSession } from '../network/http-client'
import {
getProxyBypassRulesFromEnvironment,
getProxyUrlFromEnvironment,
@@ -84,7 +85,7 @@ export async function createOpenCodeRequestSession(
authCookies: { name: string; value: string }[],
networkProxySettings?: NetworkProxySettings
): Promise<Session> {
const openCodeSession = session.fromPartition(OPENCODE_SESSION_PARTITION)
const openCodeSession = requirePartitionSession(OPENCODE_SESSION_PARTITION, 'OpenCode Go')
await clearOpenCodeSessionCookies(openCodeSession)
// The isolated cookie jar must still honor Orca, environment, and system proxies.
await ensureProxyForOpenCodeSession(openCodeSession, networkProxySettings)
@@ -7,12 +7,15 @@ const resolveProxyMock = vi.hoisted(() => vi.fn())
const setProxyMock = vi.hoisted(() => vi.fn())
const fromPartitionMock = vi.hoisted(() => vi.fn())
vi.mock('electron', () => ({
session: { fromPartition: fromPartitionMock }
}))
import { setMainHttpClient } from '../network/http-client'
import { fetchOpenCodeGoRateLimits, normalizeCookieInput } from './opencode-go-usage-fetcher'
setMainHttpClient({
fetch: (url, init) => globalThis.fetch(url, init),
proxySession: () => null,
partitionSession: fromPartitionMock
})
const WORKSPACES_SERVER_ID = 'def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f'
const CONSOLE_STATUS_URL = 'https://opencode.ai/console/api/go/status'
const CONSOLE_BILLING_STATUS_URL = 'https://opencode.ai/console/api/billing/status'
@@ -100,6 +103,21 @@ describe('fetchOpenCodeGoRateLimits', () => {
})
})
it('reports a cookie fetch as desktop-only on a host without Chromium', async () => {
setMainHttpClient(null)
try {
const result = await fetchOpenCodeGoRateLimits('auth=token')
expect(result.status).toBe('error')
expect(result.error).toContain('not available on this server')
} finally {
setMainHttpClient({
fetch: (url, init) => globalThis.fetch(url, init),
proxySession: () => null,
partitionSession: fromPartitionMock
})
}
})
it('returns unavailable when cookie is empty', async () => {
const result = await fetchOpenCodeGoRateLimits('')
+5 -60
View File
@@ -1,65 +1,10 @@
import type { CodexHomeLaunchContext } from '../ipc/pty/host-env/types'
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
import { codexHookService } from '../codex/hook-service'
import { getDefaultWslDistro } from '../wsl'
import { isAgentStatusHooksEnabledForAgent } from '../agent-hooks/managed-agent-hook-controls'
import { reconcileCodexHooksForLaunch } from '../codex/codex-hook-reconcile'
import { createCodexRuntimeHomeLaunchPreparation } from '../codex/codex-runtime-home-launch-preparation'
import { mainProcessState as state } from './main-process-state'
export async function prepareCodexRuntimeHomeForLaunch(
target?: CodexAccountSelectionTarget,
launchEnv?: NodeJS.ProcessEnv,
launchContext?: CodexHomeLaunchContext
): Promise<string | null> {
const runtimeHome = state.codexRuntimeHome
if (!runtimeHome) {
throw new Error('Codex runtime home service is not initialized')
}
// Why: a ManagedCodexHomeTemporarilyUnavailableError must escape uncaught —
// the fallbacks below all key off `null`, which means "system default", so
// swallowing the refusal would launch the wrong account (#STA-4422).
const runtimeHomePath = await runtimeHome.prepareForCodexLaunchAsync(target, launchEnv, {
unavailableManagedHomePath: launchContext?.unavailableManagedHomePath
})
const launchesCodex = launchContext?.launchesCodex === true
if (runtimeHomePath === null && target?.runtime !== 'wsl') {
// Why only a Codex launch waits: the pane spawn already schedules the reconcile, which
// writes only on a change; plain terminals and structured launches never wait on it.
if (launchesCodex) {
await reconcileCodexHooksForLaunch()
}
return null
}
const hookTarget =
target?.runtime === 'wsl'
? { runtime: 'wsl' as const, wslDistro: target.wslDistro?.trim() || getDefaultWslDistro() }
: target
const isHooksEnabled = (): boolean =>
isAgentStatusHooksEnabledForAgent(state.store?.getSettings(), 'codex')
const hooksEnabled = isHooksEnabled()
try {
// Why: honor the persisted off switch so post-startup launches can't reinstall removed hooks.
const status = await codexHookService.prepareRuntimeHomeForLaunch(
runtimeHomePath,
hookTarget,
isHooksEnabled,
launchesCodex
)
if (status.state === 'error') {
console.warn(
`[codex-hook-service] failed to ${hooksEnabled ? 'refresh' : 'refresh user'} runtime hooks before launch`,
status.detail
)
}
} catch (error) {
// Why: hook install is best-effort launch prep; a malformed hooks file must not block Codex from starting.
console.warn(
`[codex-hook-service] failed to ${hooksEnabled ? 'refresh' : 'refresh user'} runtime hooks before launch`,
error
)
}
return runtimeHomePath
}
export const prepareCodexRuntimeHomeForLaunch = createCodexRuntimeHomeLaunchPreparation({
getRuntimeHome: () => state.codexRuntimeHome,
getSettings: () => state.store?.getSettings()
})
/** The runtime's structured Codex home resolvers: a launch prepares the home that a record-less
* catalog read resolves without side effects, so a new chat's picker and the chat share one key. */
+7 -130
View File
@@ -1,133 +1,10 @@
import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume'
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
import {
trustedCodexResumeHomes,
type CodexSessionResumePreparation
} from '../codex/codex-session-resume-home'
import { prepareCodexSessionResume } from '../codex/codex-session-resume-preparation'
import {
prepareCodexAccountRestartResume,
prepareLegacySharedCodexSessionResume
} from '../codex/codex-legacy-session-resume'
import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership'
import { codexHookService } from '../codex/hook-service'
import { reconcileCodexHooksForLaunch } from '../codex/codex-hook-reconcile'
import { ensureCodexDaemonSocketGuard } from '../codex/codex-config-mirror'
import { isAgentStatusHooksEnabledForAgent } from '../agent-hooks/managed-agent-hook-controls'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { createCodexSessionResumeLaunchPreparation } from '../codex/codex-session-resume-launch-preparation'
import { mainProcessState as state } from './main-process-state'
export async function prepareCodexSessionResumeForLaunch(args: {
providerSession: AgentProviderSessionMetadata
target: CodexAccountSelectionTarget
launchEnv?: NodeJS.ProcessEnv
useSelectedAccount?: boolean
}): Promise<CodexSessionResumePreparation | null> {
const runtimeHome = state.codexRuntimeHome
const store = state.store
if (args.target.runtime === 'wsl' || !runtimeHome || !store) {
return null
}
const systemHomePath = getSystemCodexHomePath()
const trustedHomes = trustedCodexResumeHomes(runtimeHome, systemHomePath)
// Why: resolved eagerly, once, before any ranking or provenance match. The
// marker read used to be deferred into the ranking thunk so a
// provenance-present resume never paid for it, but that optimisation let an
// unreadable selected home reach the PTY as "no selection": the provenance
// branch simply omits the account from `trustedHomes` and another account's
// readable alias wins. A throw here refuses the whole resume instead
// (#STA-4422).
const selectedAccountCodexHome = runtimeHome.resolveSelectedHostAccountCodexHomePathForResume()
// Why: a `fresh` outcome must skip migration and hook repair entirely — there is
// no verified origin home to prepare, so the PTY layer drops the resume argv (#10793).
const preparation = await prepareCodexSessionResume({
sessionId: args.providerSession.id,
transcriptPath: args.providerSession.transcriptPath,
trustedCodexHomes: trustedHomes,
// Why: the legacy id rescan's winning home becomes this pane's CODEX_HOME, i.e. its account;
// rank it by the current selection so settings insertion order can never decide the account.
getSelectedAccountCodexHome: () => selectedAccountCodexHome,
systemCodexHomePath: systemHomePath,
// Why: the mirror winning is what triggers the migration into ~/.codex below, so it must
// outrank the path-sorted account homes or a system-default selection resumes as an account.
sharedRuntimeCodexHomePath: getOrcaManagedCodexHomePath(),
resolveVerifiedResumeHome: async (sessionSource) => {
let migrated = { useRealCodexHome: false }
try {
migrated = await prepareLegacySharedCodexSessionResume(
{
agent: 'codex',
executionHostId: 'local',
filePath: sessionSource.transcriptPath,
codexHome: sessionSource.homePath
},
{
isHostSystemDefaultRealHomeSelected: () =>
runtimeHome.isHostSystemDefaultRealHomeSelected(),
systemCodexHomePath: systemHomePath
}
)
} catch (error) {
// A credential-read refusal must never fall back to the old account.
if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) {
throw error
}
// Why: migration is a compatibility repair; its failure must not prevent the PTY from resuming from its trusted origin home.
console.warn(
'[codex-session-resume] Legacy rollout migration failed; using origin home:',
error
)
}
const resumeHome = args.useSelectedAccount
? await prepareCodexAccountRestartResume({
sourceHome: sessionSource.homePath,
transcriptPath: sessionSource.transcriptPath,
targetHome: selectedAccountCodexHome ?? systemHomePath,
systemCodexHomePath: systemHomePath
})
: migrated.useRealCodexHome
? systemHomePath
: sessionSource.homePath
await prepareCodexPinnedLaunchHome(resumeHome, systemHomePath)
return resumeHome
}
})
return preparation.outcome === 'resume'
? {
...preparation,
reconcileSharedRuntimeAuth:
normalizeRuntimePathForComparison(preparation.codexHomePath) ===
normalizeRuntimePathForComparison(getOrcaManagedCodexHomePath())
}
: preparation
}
const desktopResumeLaunch = createCodexSessionResumeLaunchPreparation({
getRuntimeHome: () => state.codexRuntimeHome,
getSettings: () => state.store?.getSettings()
})
/** Hook repair and the daemon-socket guard for a pane that pins `home` as its CODEX_HOME rather
* than launching in the selected account's home, which the pane spawn prepares itself. */
export async function prepareCodexPinnedLaunchHome(
home: string,
systemHomePath: string = getSystemCodexHomePath()
): Promise<void> {
const isSystemHome =
normalizeRuntimePathForComparison(home) === normalizeRuntimePathForComparison(systemHomePath)
const isHooksEnabled = (): boolean =>
isAgentStatusHooksEnabledForAgent(state.store?.getSettings(), 'codex')
try {
if (isSystemHome) {
// Why bounded: the resume waits only briefly for a reconcile; it runs on ~/.codex whatever the selection.
await reconcileCodexHooksForLaunch()
} else if (isHooksEnabled()) {
await codexHookService.installForLaunchPrep(home, true, isHooksEnabled)
} else {
await codexHookService.refreshRuntimeUserHooksForLaunchPrep(home)
}
} catch (error) {
// Why: hook repair is best-effort; it must not stop the pane from launching in this home.
console.warn('[codex-hook-service] failed to prepare automatic resume home:', error)
}
if (!isSystemHome) {
// Why: this pins the pane's CODEX_HOME, and hook repair above can skip or fail before its config mirror applies the daemon guard.
ensureCodexDaemonSocketGuard(home)
}
}
export const { prepareCodexSessionResumeForLaunch, prepareCodexPinnedLaunchHome } =
desktopResumeLaunch
+10 -152
View File
@@ -1,30 +1,16 @@
import { app } from 'electron'
import { RateLimitService } from '../rate-limits/service'
import { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service'
import { CodexAccountService } from '../codex-accounts/service'
import { ClaudeRuntimeAuthService } from '../claude-accounts/runtime-auth-service'
import { ClaudeAccountService } from '../claude-accounts/service'
import { KeybindingService } from '../keybindings/keybinding-service'
import { createProviderAccountServices } from '../provider-account-services'
import { createCodexSessionMigrationScheduler } from '../codex/codex-session-migration-scheduler'
import { startCodexSessionBackfillInBackground } from '../codex/codex-session-backfill'
import { startCodexSessionIndexHealInBackground } from '../codex/codex-session-index-heal'
import { startCodexStateDbBackfillRecoveryInBackground } from '../codex/codex-state-db-backfill-recovery'
import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths'
import { getInitialCodexRateLimitTarget } from '../rate-limits/codex-rate-limit-target'
import { getInitialClaudeRateLimitTarget } from '../rate-limits/claude-rate-limit-target'
import { getKimiRuntimeTarget, resolveKimiHome } from '../kimi/kimi-runtime-home'
import { isWslDistroRunningForBackgroundWork } from '../wsl-background-distro-gate'
import { readMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
import { readMiniMaxApiKey } from '../minimax/minimax-api-key-store'
import { readZcodePlanApiKey } from '../zcode/zcode-plan-api-key-store'
import {
hasOpenCodeGoApiKey,
readOpenCodeGoApiKey,
saveOpenCodeGoApiKey
} from '../opencode/opencode-go-api-key-store'
import { createAccountRuntimeTargetSettingsSync } from '../rate-limits/account-runtime-target-sync'
import { normalizeCodexRuntimeSelection } from '../codex-accounts/runtime-selection'
import { normalizeClaudeRuntimeSelection } from '../claude-accounts/runtime-selection'
import { agentHookServer } from '../agent-hooks/server'
import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home'
import { browserManager } from '../browser/browser-manager'
@@ -56,9 +42,6 @@ export function initializeMainProcessAccountServices(): void {
) {
throw new Error('Usage stores must be initialized before account services')
}
state.rateLimits = new RateLimitService()
state.codexRuntimeHome = new CodexRuntimeHomeService(store)
void startCodexStateDbBackfillRecoveryInBackground(getOrcaManagedCodexHomePath())
state.codexSessionMigration = createCodexSessionMigrationScheduler({
isEligible: () =>
state.codexRuntimeHome?.isHostSystemDefaultSessionMigrationEligible() === true,
@@ -71,122 +54,35 @@ export function initializeMainProcessAccountServices(): void {
startBackfill: startCodexSessionBackfillInBackground,
startIndexHeal: startCodexSessionIndexHealInBackground
})
state.codexAccounts = new CodexAccountService(store, state.rateLimits, state.codexRuntimeHome, {
onHostSystemDefaultSelected: state.codexSessionMigration.requestRun
const services = createProviderAccountServices(store, {
codexAccountLifecycle: { onHostSystemDefaultSelected: state.codexSessionMigration.requestRun },
setClaudeStatusLineListener: (listener) => agentHookServer.setClaudeStatusLineListener(listener)
})
state.rateLimits = services.rateLimits
state.codexRuntimeHome = services.codexRuntimeHome
state.codexAccounts = services.codexAccounts
state.claudeRuntimeAuth = services.claudeRuntimeAuth
state.claudeAccounts = services.claudeAccounts
void startCodexStateDbBackfillRecoveryInBackground(getOrcaManagedCodexHomePath())
// Why: migrate historical shared-home sessions after startup; compatibility
// launches re-arm the non-destructive pass for new rollouts (#4444, #8612, #12480).
state.codexSessionMigration.scheduleInitialRun()
state.claudeRuntimeAuth = new ClaudeRuntimeAuthService(store)
state.claudeAccounts = new ClaudeAccountService(store, state.rateLimits, state.claudeRuntimeAuth)
// Why: quota polls are background work and must not boot a stopped distro; user-driven
// resets call prepareForRateLimitFetch directly.
state.rateLimits.setCodexHomePathResolver(async (target) =>
target?.runtime === 'wsl' && !(await isWslDistroRunningForBackgroundWork(target.wslDistro))
? { kind: 'skip' }
: state.codexRuntimeHome!.prepareForRateLimitFetch(target)
)
state.rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings()))
// Why: Kimi's CLI refreshes its OAuth token in whichever runtime it runs in, so the
// usage fetch must read the WSL-side credentials when that's the configured runtime (#12370).
state.rateLimits.setKimiHomeResolver(() =>
resolveKimiHome(getKimiRuntimeTarget(store.getSettings()))
)
state.rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings()))
const syncAccountRuntimeTargets = createAccountRuntimeTargetSettingsSync(
state.rateLimits,
store.getSettings()
)
const expireModelCatalogs = createAgentModelCatalogSettingsExpiry(
agentModelCatalogStore,
store.getSettings()
)
store.onSettingsChanged((updates, settings) => {
expireModelCatalogs(updates, settings)
// Why: auto is a live policy; retarget only providers whose settings-derived runtime changed.
void syncAccountRuntimeTargets(updates, settings).catch((error) =>
console.warn('[rate-limits] Failed to apply account runtime target:', error)
)
// An account switch would otherwise leave the next chat's picker on a cold catalog.
if (MODEL_CATALOG_ACCOUNT_SETTINGS.some((key) => key in updates)) {
prewarmStructuredAgentModelCatalogs()
}
if ('opencodeSessionCookie' in updates || 'opencodeWorkspaceId' in updates) {
state.rateLimits?.invalidateOpenCodeGoCredentialState()
void state.rateLimits?.refresh().catch((error: unknown) => {
console.warn(
'[rate-limits] Failed to refresh OpenCode Go usage after a settings change:',
error
)
})
}
// Why: these three pick the MiniMax host and quota bucket, so a stale snapshot from the
// previous endpoint would otherwise sit in the status bar until the next poll.
if (
'minimaxEndpoint' in updates ||
'minimaxGroupId' in updates ||
'minimaxUsageModels' in updates
) {
state.rateLimits?.invalidateMiniMaxCredentialState()
void state.rateLimits?.refresh().catch((error: unknown) => {
console.warn(
'[rate-limits] Failed to refresh MiniMax usage after a settings change:',
error
)
})
}
// Why: the site picks the GLM Coding Plan quota host, so a stale snapshot from
// the previous site would otherwise sit in the status bar until the next poll.
if ('zcodePlanSite' in updates) {
state.rateLimits?.invalidateZcodeCredentialState()
void state.rateLimits?.refresh().catch((error: unknown) => {
console.warn(
'[rate-limits] Failed to refresh GLM Coding Plan usage after a settings change:',
error
)
})
}
})
state.rateLimits.setClaudeAuthPreparationResolver((target) =>
state.claudeRuntimeAuth!.prepareForRateLimitFetch(target)
)
// Why: live Claude sessions stream usage windows through their statusLine command; feeding them here avoids OAuth usage-endpoint polling (and its 429s).
agentHookServer.setClaudeStatusLineListener((event) => {
state.rateLimits!.ingestLiveClaudeRateLimits(event)
})
store.migrateLegacyOpenCodeGoApiKey({
has: hasOpenCodeGoApiKey,
read: readOpenCodeGoApiKey,
save: saveOpenCodeGoApiKey
})
state.rateLimits.setOpenCodeGoConfigResolver(() => {
const settings = store.getSettings()
return {
sessionCookie: settings.opencodeSessionCookie,
workspaceIdOverride: settings.opencodeWorkspaceId
}
}, readOpenCodeGoApiKey)
state.rateLimits.setMiniMaxConfigResolver(() => {
const settings = store.getSettings()
const apiKey = readMiniMaxApiKey() ?? ''
return {
sessionCookie: apiKey ? '' : (readMiniMaxSessionCookie() ?? ''),
groupId: settings.minimaxGroupId,
models: settings.minimaxUsageModels,
endpoint: settings.minimaxEndpoint,
apiKey
}
})
state.rateLimits.setZcodePlanConfigResolver(() => ({
site: store.getSettings().zcodePlanSite ?? 'zai',
apiKey: readZcodePlanApiKey() ?? ''
}))
state.rateLimits.setGeminiCliOAuthEnabledResolver(() => store.getSettings().geminiCliOAuthEnabled)
// Reuse the meter switch so hidden Antigravity usage does not spawn agy.
state.rateLimits.setAntigravityUsageEnabledResolver(() =>
store.getUI().statusBarItems.includes('antigravity')
)
state.rateLimits.setNetworkProxySettingsResolver(() => store.getSettings())
state.keybindings = new KeybindingService({
homePath: app.getPath('home'),
getLegacyOverrides: () => store.getSettings().keybindings,
@@ -196,42 +92,4 @@ export function initializeMainProcessAccountServices(): void {
}
})
browserManager.setSettingsResolver(() => ({ keybindings: state.keybindings?.getOverrides() }))
state.rateLimits.setInactiveClaudeAccountsResolver(() => {
const settings = store.getSettings()
const activeIds = new Set(
[
normalizeClaudeRuntimeSelection(settings).host,
...Object.values(normalizeClaudeRuntimeSelection(settings).wsl)
].filter(Boolean)
)
return settings.claudeManagedAccounts
.filter((account) => !activeIds.has(account.id))
.map((account) => ({
id: account.id,
managedAuthRuntime: account.managedAuthRuntime,
wslDistro: account.wslDistro,
wslLinuxAuthPath: account.wslLinuxAuthPath
}))
})
state.rateLimits.setInactiveCodexAccountsResolver(() => {
const settings = store.getSettings()
const activeIds = new Set(
[
normalizeCodexRuntimeSelection(settings).host,
...Object.values(normalizeCodexRuntimeSelection(settings).wsl)
].filter(Boolean)
)
return settings.codexManagedAccounts
.filter((account) => !activeIds.has(account.id))
.map((account) => ({
id: account.id,
resolveHome: () => {
const resolved =
state.codexRuntimeHome!.resolveCodexManagedAccountHomeForInactiveFetch(account)
return resolved.kind === 'ready'
? { kind: 'ready' as const, managedHomePath: resolved.homePath }
: { kind: 'skip' as const }
}
}))
})
}
@@ -2,6 +2,7 @@ import * as fs from 'node:fs'
import * as os from 'node:os'
import { join } from 'node:path'
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import { setSecretStore } from '../../shared/secret-store'
import type * as WindowsAcl from '../../shared/secure-path-windows-acl'
import type * as Store from './zcode-plan-api-key-store'
@@ -25,7 +26,18 @@ const state = vi.hoisted((): NativeCase => ({
nativeOutcomes: [],
directories: []
}))
vi.mock('electron', () => ({ safeStorage: { isEncryptionAvailable: () => false } }))
beforeEach(() =>
setSecretStore({
isEncryptionAvailable: () => false,
encryptString: () => {
throw new Error('unavailable')
},
decryptString: () => {
throw new Error('unavailable')
},
describeProtectionGap: () => 'unavailable'
})
)
vi.mock('node:os', async (original) => ({
...(await original<typeof os>()),
homedir: () => state.home
@@ -2,6 +2,7 @@ import type * as fs from 'node:fs'
import * as os from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setSecretStore } from '../../shared/secret-store'
import type * as SecureFile from '../../shared/secure-file'
import type * as ZcodePlanApiKeyStore from './zcode-plan-api-key-store'
@@ -12,7 +13,18 @@ const mocks = vi.hoisted(() => ({
restrict: vi.fn((_path: string, _directory: boolean) => false)
}))
vi.mock('electron', () => ({ safeStorage: { isEncryptionAvailable: () => false } }))
beforeEach(() =>
setSecretStore({
isEncryptionAvailable: () => false,
encryptString: () => {
throw new Error('unavailable')
},
decryptString: () => {
throw new Error('unavailable')
},
describeProtectionGap: () => 'unavailable'
})
)
vi.mock('node:os', async (original) => ({
...(await original<typeof os>()),
homedir: () => mocks.home
@@ -11,13 +11,25 @@ import {
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setSecretStore } from '../../shared/secret-store'
import type * as Fs from 'node:fs'
import type * as SecureFile from '../../shared/secure-file'
import type * as ZcodePlanApiKeyStore from './zcode-plan-api-key-store'
const restriction = vi.hoisted(() => vi.fn((_path: string, _directory: boolean) => false))
vi.mock('electron', () => ({ safeStorage: { isEncryptionAvailable: () => false } }))
beforeEach(() =>
setSecretStore({
isEncryptionAvailable: () => false,
encryptString: () => {
throw new Error('unavailable')
},
decryptString: () => {
throw new Error('unavailable')
},
describeProtectionGap: () => 'unavailable'
})
)
// Stands in for the born-restricted create: a real staged write, then the injected outcome.
vi.mock('../../shared/secure-path-windows-acl', async () => {
const { writeFileSync: stage } = await vi.importActual<typeof Fs>('node:fs')
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setSecretStore } from '../../shared/secret-store'
import type * as ZcodePlanApiKeyStore from './zcode-plan-api-key-store'
const safeStorageMock = vi.hoisted(() => ({
@@ -7,11 +8,7 @@ const safeStorageMock = vi.hoisted(() => ({
decryptString: vi.fn((value: Buffer) => value.toString('utf8'))
}))
const electronMock = vi.hoisted(() => ({
safeStorage: safeStorageMock
}))
vi.mock('electron', () => electronMock)
beforeEach(() => setSecretStore({ ...safeStorageMock, describeProtectionGap: () => null }))
const existsSyncMock = vi.fn()
const readFileSyncMock = vi.fn()
+5 -5
View File
@@ -1,4 +1,4 @@
import { guardedSafeStorage as safeStorage } from '../host/safe-storage-availability'
import { getSecretStore } from '../../shared/secret-store'
import { existsSync, readFileSync, rmSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
@@ -47,10 +47,10 @@ function readEnvelope(envelope: ZcodePlanApiKeyEnvelope): string {
if (envelope.kind === 'plaintext') {
return envelope.payload.toString('utf8')
}
if (!safeStorage.isEncryptionAvailable()) {
if (!getSecretStore().isEncryptionAvailable()) {
throw new Error('GLM Coding Plan API key could not be decrypted')
}
return safeStorage.decryptString(envelope.payload)
return getSecretStore().decryptString(envelope.payload)
}
export function hasZcodePlanApiKey(): boolean {
@@ -123,10 +123,10 @@ export function saveZcodePlanApiKey(key: string): void {
if (/[\r\n]/.test(trimmed)) {
throw new Error('GLM Coding Plan API key must be a single line')
}
if (safeStorage.isEncryptionAvailable()) {
if (getSecretStore().isEncryptionAvailable()) {
writeSecureFile(
getZcodePlanApiKeyPath(),
encodeApiKeyEnvelope('encrypted', safeStorage.encryptString(trimmed))
encodeApiKeyEnvelope('encrypted', getSecretStore().encryptString(trimmed))
)
cachedZcodePlanApiKey = trimmed
return
+3
View File
@@ -181,6 +181,9 @@ export const ORCAD_ARTIFACTS: readonly OrcadArtifact[] = [
{ filename: ORCAD_PORT_SCAN_COMMAND_WORKER_ENTRY },
// Forked so transcript parsing stays off the server's event loop; session search stays in-process.
{ filename: ORCAD_SESSION_SCANNER_SERVICE_ENTRY },
// Worker threads the account services start: CLI probes off the event loop, Claude account setup.
{ filename: 'worker-thread-child-process-entry.js' },
{ filename: 'claude-profile-setup-worker-entry.js' },
// Target-specific even when the JavaScript bundle is shared across packaged slots.
{ filename: ORCAD_SERVER_TARGET_FILENAME },
// orcad never depends on a host runtime or host-installed native module.