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