diff --git a/config/scripts/orcad-entry-build.mjs b/config/scripts/orcad-entry-build.mjs index 7b636937b3e..dc65783e8af 100644 --- a/config/scripts/orcad-entry-build.mjs +++ b/config/scripts/orcad-entry-build.mjs @@ -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. */ diff --git a/src/main/codex-accounts/codex-managed-home-path.ts b/src/main/codex-accounts/codex-managed-home-path.ts index 012ef943aa4..37df7d27b17 100644 --- a/src/main/codex-accounts/codex-managed-home-path.ts +++ b/src/main/codex-accounts/codex-managed-home-path.ts @@ -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 }) diff --git a/src/main/codex-accounts/runtime-home-catalog-account-key.test.ts b/src/main/codex-accounts/runtime-home-catalog-account-key.test.ts index ac6998ad71d..46881926fe4 100644 --- a/src/main/codex-accounts/runtime-home-catalog-account-key.test.ts +++ b/src/main/codex-accounts/runtime-home-catalog-account-key.test.ts @@ -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('node:os') diff --git a/src/main/codex-accounts/runtime-home-catalog-probe-prep.test.ts b/src/main/codex-accounts/runtime-home-catalog-probe-prep.test.ts index b0675aac009..6767d7f8b95 100644 --- a/src/main/codex-accounts/runtime-home-catalog-probe-prep.test.ts +++ b/src/main/codex-accounts/runtime-home-catalog-probe-prep.test.ts @@ -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('node:os') diff --git a/src/main/codex-accounts/runtime-home-legacy-migration.test.ts b/src/main/codex-accounts/runtime-home-legacy-migration.test.ts index ad6513a7730..0e015ea5c15 100644 --- a/src/main/codex-accounts/runtime-home-legacy-migration.test.ts +++ b/src/main/codex-accounts/runtime-home-legacy-migration.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex-accounts/runtime-home-managed-auth-recovery.test.ts b/src/main/codex-accounts/runtime-home-managed-auth-recovery.test.ts index 627d6519859..df1cc5ae2b0 100644 --- a/src/main/codex-accounts/runtime-home-managed-auth-recovery.test.ts +++ b/src/main/codex-accounts/runtime-home-managed-auth-recovery.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex-accounts/runtime-home-mirror-login-copy.test.ts b/src/main/codex-accounts/runtime-home-mirror-login-copy.test.ts index b9ffca73e0c..a3d5d99843c 100644 --- a/src/main/codex-accounts/runtime-home-mirror-login-copy.test.ts +++ b/src/main/codex-accounts/runtime-home-mirror-login-copy.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts b/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts index 58b48c099bd..0e723cfc5e6 100644 --- a/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts +++ b/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts @@ -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 = {} vi.mock('electron', () => ({ app: { getPath: () => testState.userData } })) +beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userData })) vi.mock('node:os', async () => { const actual = await vi.importActual('node:os') return { ...actual, homedir: () => testState.home } diff --git a/src/main/codex-accounts/runtime-home-per-account-homes.test.ts b/src/main/codex-accounts/runtime-home-per-account-homes.test.ts index 905f12acc2b..833d4cd74ee 100644 --- a/src/main/codex-accounts/runtime-home-per-account-homes.test.ts +++ b/src/main/codex-accounts/runtime-home-per-account-homes.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex-accounts/runtime-home-read-only-launch-resolution.test.ts b/src/main/codex-accounts/runtime-home-read-only-launch-resolution.test.ts index 506d88144e2..a89d0035a46 100644 --- a/src/main/codex-accounts/runtime-home-read-only-launch-resolution.test.ts +++ b/src/main/codex-accounts/runtime-home-read-only-launch-resolution.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts b/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts index 2e781997eb0..c9d22975968 100644 --- a/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts +++ b/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex-accounts/runtime-home-resume-selection-gate.test.ts b/src/main/codex-accounts/runtime-home-resume-selection-gate.test.ts index 89390cc80e0..8637324acd4 100644 --- a/src/main/codex-accounts/runtime-home-resume-selection-gate.test.ts +++ b/src/main/codex-accounts/runtime-home-resume-selection-gate.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex-accounts/runtime-home-retained-auth-provenance.test.ts b/src/main/codex-accounts/runtime-home-retained-auth-provenance.test.ts index 731962a580a..562b1e41dce 100644 --- a/src/main/codex-accounts/runtime-home-retained-auth-provenance.test.ts +++ b/src/main/codex-accounts/runtime-home-retained-auth-provenance.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex-accounts/runtime-home-service-paths.ts b/src/main/codex-accounts/runtime-home-service-paths.ts index de13c208566..415370ce675 100644 --- a/src/main/codex-accounts/runtime-home-service-paths.ts +++ b/src/main/codex-accounts/runtime-home-service-paths.ts @@ -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 { diff --git a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts index bbadd109112..68e6f747898 100644 --- a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts +++ b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts @@ -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('node:os') return { ...actual, homedir: () => testState.home } diff --git a/src/main/codex-accounts/runtime-home-system-default-mirror-readback.test.ts b/src/main/codex-accounts/runtime-home-system-default-mirror-readback.test.ts index 8a8d29b4b8c..a7ac0df8f58 100644 --- a/src/main/codex-accounts/runtime-home-system-default-mirror-readback.test.ts +++ b/src/main/codex-accounts/runtime-home-system-default-mirror-readback.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex-accounts/runtime-home-system-default-snapshot.test.ts b/src/main/codex-accounts/runtime-home-system-default-snapshot.test.ts index 075ca734eb8..251f206d03b 100644 --- a/src/main/codex-accounts/runtime-home-system-default-snapshot.test.ts +++ b/src/main/codex-accounts/runtime-home-system-default-snapshot.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex-accounts/service-account-add-login.test.ts b/src/main/codex-accounts/service-account-add-login.test.ts index d9b963d8bc9..0f3ba349eeb 100644 --- a/src/main/codex-accounts/service-account-add-login.test.ts +++ b/src/main/codex-accounts/service-account-add-login.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex-accounts/service-account-selection-and-removal.test.ts b/src/main/codex-accounts/service-account-selection-and-removal.test.ts index 861859223ea..974d9bcf24c 100644 --- a/src/main/codex-accounts/service-account-selection-and-removal.test.ts +++ b/src/main/codex-accounts/service-account-selection-and-removal.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex-accounts/service-managed-home-removal-retries.test.ts b/src/main/codex-accounts/service-managed-home-removal-retries.test.ts index 0afcd50b03e..26d0113a4ae 100644 --- a/src/main/codex-accounts/service-managed-home-removal-retries.test.ts +++ b/src/main/codex-accounts/service-managed-home-removal-retries.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex-accounts/service-reauthenticate-activation.test.ts b/src/main/codex-accounts/service-reauthenticate-activation.test.ts index 37cd2a5e741..e1de296f922 100644 --- a/src/main/codex-accounts/service-reauthenticate-activation.test.ts +++ b/src/main/codex-accounts/service-reauthenticate-activation.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex-accounts/service-reset-credit-durability.test.ts b/src/main/codex-accounts/service-reset-credit-durability.test.ts index e524bda2aeb..e43a803a933 100644 --- a/src/main/codex-accounts/service-reset-credit-durability.test.ts +++ b/src/main/codex-accounts/service-reset-credit-durability.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts b/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts index eebc256a8c6..dc253c21ee2 100644 --- a/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts +++ b/src/main/codex-accounts/service-reset-credit-home-ownership.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex-accounts/service-reset-credit-target-routing.test.ts b/src/main/codex-accounts/service-reset-credit-target-routing.test.ts index 1485794c5f9..470af7c34bb 100644 --- a/src/main/codex-accounts/service-reset-credit-target-routing.test.ts +++ b/src/main/codex-accounts/service-reset-credit-target-routing.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex-accounts/service-system-default-identity.test.ts b/src/main/codex-accounts/service-system-default-identity.test.ts index 3cbca5b6acc..9256f9fb065 100644 --- a/src/main/codex-accounts/service-system-default-identity.test.ts +++ b/src/main/codex-accounts/service-system-default-identity.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 6081b4b1369..efa3131ed47 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex-accounts/sta-4422-transient-ownership-error.test.ts b/src/main/codex-accounts/sta-4422-transient-ownership-error.test.ts index 74293630c73..171236de669 100644 --- a/src/main/codex-accounts/sta-4422-transient-ownership-error.test.ts +++ b/src/main/codex-accounts/sta-4422-transient-ownership-error.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex-accounts/sta-4734-login-rollback-deletes-authenticated-home.test.ts b/src/main/codex-accounts/sta-4734-login-rollback-deletes-authenticated-home.test.ts index 8e4c90d1b68..dd00143d4f2 100644 --- a/src/main/codex-accounts/sta-4734-login-rollback-deletes-authenticated-home.test.ts +++ b/src/main/codex-accounts/sta-4734-login-rollback-deletes-authenticated-home.test.ts @@ -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('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() diff --git a/src/main/codex/codex-runtime-home-launch-preparation.ts b/src/main/codex/codex-runtime-home-launch-preparation.ts new file mode 100644 index 00000000000..f0206e7cf4b --- /dev/null +++ b/src/main/codex/codex-runtime-home-launch-preparation.ts @@ -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 => { + 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 + } +} diff --git a/src/main/codex/codex-session-resume-launch-preparation.ts b/src/main/codex/codex-session-resume-launch-preparation.ts new file mode 100644 index 00000000000..8936b53f92b --- /dev/null +++ b/src/main/codex/codex-session-resume-launch-preparation.ts @@ -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 { + 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 { + 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 } +} diff --git a/src/main/credentials/encrypted-api-key-file-store.ts b/src/main/credentials/encrypted-api-key-file-store.ts index 0d5999e03c6..bc6d985f284 100644 --- a/src/main/credentials/encrypted-api-key-file-store.ts +++ b/src/main/credentials/encrypted-api-key-file-store.ts @@ -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 diff --git a/src/main/host/electron-http-client.ts b/src/main/host/electron-http-client.ts index e120783c7d5..9f4cb8eac5a 100644 --- a/src/main/host/electron-http-client.ts +++ b/src/main/host/electron-http-client.ts @@ -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) } diff --git a/src/main/minimax/minimax-api-key-store.test.ts b/src/main/minimax/minimax-api-key-store.test.ts index 90ac8012fc5..0acd20fa953 100644 --- a/src/main/minimax/minimax-api-key-store.test.ts +++ b/src/main/minimax/minimax-api-key-store.test.ts @@ -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() diff --git a/src/main/minimax/minimax-cookie-store.test.ts b/src/main/minimax/minimax-cookie-store.test.ts index c7530b82031..6acb74f7b4d 100644 --- a/src/main/minimax/minimax-cookie-store.test.ts +++ b/src/main/minimax/minimax-cookie-store.test.ts @@ -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(() => { diff --git a/src/main/minimax/minimax-cookie-store.ts b/src/main/minimax/minimax-cookie-store.ts index 0fb54dcd6b1..f75655b0e16 100644 --- a/src/main/minimax/minimax-cookie-store.ts +++ b/src/main/minimax/minimax-cookie-store.ts @@ -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 diff --git a/src/main/network/http-client.ts b/src/main/network/http-client.ts index 03dfbc18a3f..9f5b7b08da0 100644 --- a/src/main/network/http-client.ts +++ b/src/main/network/http-client.ts @@ -23,6 +23,8 @@ export type MainHttpClient = { fetch(url: string, init?: RequestInit): Promise /** 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) +} diff --git a/src/main/opencode/opencode-go-api-key-store.test.ts b/src/main/opencode/opencode-go-api-key-store.test.ts index 728665076f9..9b492b263f6 100644 --- a/src/main/opencode/opencode-go-api-key-store.test.ts +++ b/src/main/opencode/opencode-go-api-key-store.test.ts @@ -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 diff --git a/src/main/orcad/orcad-account-services.test.ts b/src/main/orcad/orcad-account-services.test.ts new file mode 100644 index 00000000000..5bfe06cdb07 --- /dev/null +++ b/src/main/orcad/orcad-account-services.test.ts @@ -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 { + // 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 { + const services = create(store, isWslHost) + services?.attachToRuntime(runtime) + return services +} + +async function call(runtime: OrcaRuntimeService, name: string, params: unknown): Promise { + 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() + }) +}) diff --git a/src/main/orcad/orcad-account-services.ts b/src/main/orcad/orcad-account-services.ts new file mode 100644 index 00000000000..521d6fd7c73 --- /dev/null +++ b/src/main/orcad/orcad-account-services.ts @@ -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 + 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 + resolveCodexStructuredLaunchHome: (input: StructuredLaunchInput) => string | null + prepareCodexCatalogProbeHome: (homePath: string) => void + prepareAiVaultSessionResume: ( + args: AiVaultPrepareSessionResumeArgs + ) => Promise + } + /** 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()) + }) + } + } +} diff --git a/src/main/orcad/orcad-codex-account-homes.ts b/src/main/orcad/orcad-codex-account-homes.ts index 9366f6a5428..8ef17653529 100644 --- a/src/main/orcad/orcad-codex-account-homes.ts +++ b/src/main/orcad/orcad-codex-account-homes.ts @@ -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() } } diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 8dae0166b28..841a68629d9 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -72,8 +72,11 @@ export async function startOrcad(options: OrcadOptions = {}): Promise 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 diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 0b2e252773f..36f8f0e71c5 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -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 = {} diff --git a/src/main/provider-account-services.ts b/src/main/provider-account-services.ts new file mode 100644 index 00000000000..218b2e0ca17 --- /dev/null +++ b/src/main/provider-account-services.ts @@ -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[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 } +} diff --git a/src/main/rate-limits/minimax/minimax-fetcher.test.ts b/src/main/rate-limits/minimax/minimax-fetcher.test.ts index c1fd74ac488..22a06d9e980 100644 --- a/src/main/rate-limits/minimax/minimax-fetcher.test.ts +++ b/src/main/rate-limits/minimax/minimax-fetcher.test.ts @@ -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' diff --git a/src/main/rate-limits/minimax/minimax-request-context.test.ts b/src/main/rate-limits/minimax/minimax-request-context.test.ts index 47681bd70b5..c372e7bad04 100644 --- a/src/main/rate-limits/minimax/minimax-request-context.test.ts +++ b/src/main/rate-limits/minimax/minimax-request-context.test.ts @@ -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' diff --git a/src/main/rate-limits/minimax/minimax-request-context.ts b/src/main/rate-limits/minimax/minimax-request-context.ts index e31e3fd5421..a2adea8bd37 100644 --- a/src/main/rate-limits/minimax/minimax-request-context.ts +++ b/src/main/rate-limits/minimax/minimax-request-context.ts @@ -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 { // 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 { - 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 { - const miniMaxSession = session.fromPartition(MINIMAX_SESSION_PARTITION) + const miniMaxSession = requirePartitionSession(MINIMAX_SESSION_PARTITION, 'MiniMax') const origin = getMiniMaxOrigin(args.endpointMode) try { await clearMiniMaxSessionCookieJarForSession(miniMaxSession, origin) diff --git a/src/main/rate-limits/opencode-go-request-session.ts b/src/main/rate-limits/opencode-go-request-session.ts index d86033d35e0..0388785ae47 100644 --- a/src/main/rate-limits/opencode-go-request-session.ts +++ b/src/main/rate-limits/opencode-go-request-session.ts @@ -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 { - 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) diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.test.ts b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts index 35e6094ba09..6024455ced8 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.test.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts @@ -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('') diff --git a/src/main/startup/codex-launch-preparation.ts b/src/main/startup/codex-launch-preparation.ts index f60345640ac..d5343f378ee 100644 --- a/src/main/startup/codex-launch-preparation.ts +++ b/src/main/startup/codex-launch-preparation.ts @@ -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 { - 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. */ diff --git a/src/main/startup/codex-session-resume-launch.ts b/src/main/startup/codex-session-resume-launch.ts index cd7a6619851..d331631e3f6 100644 --- a/src/main/startup/codex-session-resume-launch.ts +++ b/src/main/startup/codex-session-resume-launch.ts @@ -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 { - 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 { - 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 diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index bb54e610d02..380a0cf7fcb 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -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 } - } - })) - }) } diff --git a/src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts b/src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts index 56897f1801b..e1214b2a4f4 100644 --- a/src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts +++ b/src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts @@ -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()), homedir: () => state.home diff --git a/src/main/zcode/zcode-plan-api-key-store-plaintext-rollback.test.ts b/src/main/zcode/zcode-plan-api-key-store-plaintext-rollback.test.ts index 88cb7d844b2..c44a7c949c5 100644 --- a/src/main/zcode/zcode-plan-api-key-store-plaintext-rollback.test.ts +++ b/src/main/zcode/zcode-plan-api-key-store-plaintext-rollback.test.ts @@ -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()), homedir: () => mocks.home diff --git a/src/main/zcode/zcode-plan-api-key-store-publication-race.test.ts b/src/main/zcode/zcode-plan-api-key-store-publication-race.test.ts index f55a210ea0f..300d4b9a22f 100644 --- a/src/main/zcode/zcode-plan-api-key-store-publication-race.test.ts +++ b/src/main/zcode/zcode-plan-api-key-store-publication-race.test.ts @@ -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('node:fs') diff --git a/src/main/zcode/zcode-plan-api-key-store.test.ts b/src/main/zcode/zcode-plan-api-key-store.test.ts index 7e9f6f6b7b6..58b70ecadb0 100644 --- a/src/main/zcode/zcode-plan-api-key-store.test.ts +++ b/src/main/zcode/zcode-plan-api-key-store.test.ts @@ -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() diff --git a/src/main/zcode/zcode-plan-api-key-store.ts b/src/main/zcode/zcode-plan-api-key-store.ts index c4e217e56cb..320a1b3f794 100644 --- a/src/main/zcode/zcode-plan-api-key-store.ts +++ b/src/main/zcode/zcode-plan-api-key-store.ts @@ -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 diff --git a/src/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index 25f299e54db..a5487b9afd2 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -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.