From f888acdb65c06ff9c300ece879ca4f68b842822c Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:36:16 -0400 Subject: [PATCH] Revert Codex launch homes to shared runtime (#4400) Co-authored-by: Orca --- config/tsconfig.cli.json | 2 - ...odex-account-auth-isolated-launch-homes.md | 218 ---- .../runtime-home-service.test.ts | 1038 +++-------------- .../codex-accounts/runtime-home-service.ts | 686 ++++------- src/main/codex-accounts/service.test.ts | 93 +- src/main/codex-accounts/service.ts | 5 - src/main/codex/codex-config-mirror.test.ts | 822 +------------ src/main/codex/codex-config-mirror.ts | 652 +---------- src/main/codex/codex-config-sync-state.ts | 151 --- .../codex/codex-launch-home-paths.test.ts | 169 --- src/main/codex/codex-launch-home-paths.ts | 895 -------------- src/main/codex/config-toml-trust.ts | 34 - src/main/codex/hook-service.test.ts | 72 +- src/main/codex/hook-service.ts | 103 +- src/main/daemon/pty-subprocess.test.ts | 15 +- src/main/index.ts | 3 - src/main/ipc/pty.test.ts | 64 +- src/main/providers/local-pty-provider.test.ts | 43 +- 18 files changed, 576 insertions(+), 4489 deletions(-) delete mode 100644 docs/codex-account-auth-isolated-launch-homes.md delete mode 100644 src/main/codex/codex-config-sync-state.ts delete mode 100644 src/main/codex/codex-launch-home-paths.test.ts delete mode 100644 src/main/codex/codex-launch-home-paths.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index f1599bb09..e606895e6 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -11,9 +11,7 @@ "../src/main/claude/hook-settings.ts", "../src/main/claude/hook-service.ts", "../src/main/codex/codex-config-mirror.ts", - "../src/main/codex/codex-config-sync-state.ts", "../src/main/codex/codex-home-paths.ts", - "../src/main/codex/codex-launch-home-paths.ts", "../src/main/codex/config-toml-trust.ts", "../src/main/codex/hook-service.ts", "../src/main/codex-accounts/fs-utils.ts", diff --git a/docs/codex-account-auth-isolated-launch-homes.md b/docs/codex-account-auth-isolated-launch-homes.md deleted file mode 100644 index 42f159931..000000000 --- a/docs/codex-account-auth-isolated-launch-homes.md +++ /dev/null @@ -1,218 +0,0 @@ -# Codex Account Auth-Isolated Launch Homes - -## Problem - -Orca stopped launching Codex from the user's global `~/.codex` because global hook and config mutations were intrusive. The current Orca-owned host runtime home is shared across accounts: - -- [src/main/codex-accounts/runtime-home-service.ts](../src/main/codex-accounts/runtime-home-service.ts:119) prepares launch and rate-limit homes. -- [src/main/codex-accounts/runtime-home-service.ts](../src/main/codex-accounts/runtime-home-service.ts:1093) materializes an active pointer for terminal launches. -- [src/main/ipc/pty.ts](../src/main/ipc/pty.ts:608) injects the selected `CODEX_HOME` into PTYs. -- [src/renderer/src/lib/codex-session-restart.ts](../src/renderer/src/lib/codex-session-restart.ts:24) marks only currently foreground Codex processes for restart after account switches. - -PR #1629 fixed stored credential clobbering by verifying identity before read-back. That prevents Account A tokens from being saved into Account B's managed account. It does not remove the live-process race where Account A can still write the shared runtime `auth.json` after the user selects Account B, and a later launch can observe Account A before Orca re-syncs. - -## Goal - -Account switching should switch only Codex identity. Config, `/model`, `/fast`, hooks, sessions, skills, plugins, prompts, themes, and usage history should behave like one shared Codex environment. - -Implementation should isolate only `auth.json` by selected account while preserving one shared Orca Codex environment for every other file Codex needs. - -## Core Invariants - -- Native `~/.codex` is user-owned. Orca may read/copy from it, but Orca-owned hooks and runtime config live in Orca userData. -- `codex-runtime-home/home` is the single shared Orca Codex environment. -- Host launch homes may contain a real selected `auth.json`. Known shared Codex entries resolve to the shared environment or are reconciled back into it before another selected launch home is prepared. -- Terminal `CODEX_HOME` points at a stable active home. Account switching repoints that active home to the selected launch home so the next `codex` command in an existing shell resolves to the current account. - -## Non-goals - -- Do not return to mutating the user's global `~/.codex` for Orca hooks or runtime config. -- Do not make account switching fork user preferences or session history by account. -- Do not remove the existing #1629 read-back guard; it is still needed for token refresh persistence. -- Do not redesign SSH remote Codex home handling in this change. SSH still uses the remote user's Codex home and remote hook install flow. -- Do not add visible product UI unless validation shows the existing account switcher becomes misleading. - -## Design - -1. Split host Codex runtime storage into a shared environment home and selected launch homes. - - Keep `codex-runtime-home/home` as the shared environment home. It owns `config.toml`, `hooks.json`, linked/copied user resources, and shared `sessions`. - - Add selected launch homes under `codex-runtime-home/launch/host//home`, where `` is `system` or `account-`. These homes contain a real `auth.json` for the selected identity and links/copies/reconciled files for non-auth entries back to the shared environment home. Raw account ids, emails, and workspace labels must never be used as path segments. - -2. Prepare the shared environment first. - - Host `prepareForCodexLaunch()` and `prepareForRateLimitFetch()` continue to sync system resources, config, hooks, and sessions into the shared environment home before preparing a launch home. This keeps `/model`, `/fast`, hook trust, and session history shared for fresh host launches. - -3. Put only auth in the selected launch home. - - For managed accounts, copy the selected managed account's `auth.json` into that account's launch home. For system default, mirror the current system-default auth into the system launch home or remove `auth.json` if the user is logged out. - - Read-back still uses the existing identity guard and persists refreshed tokens to the matching managed account. After read-back, the launch home is re-written from the selected source of truth. - -4. Link non-auth launch-home entries to the shared environment. - - The launch home exposes Orca's known shared Codex entries except `auth.json` and Orca metadata by symlink/junction where possible. This includes `config.toml`, `hooks.json`, `history.jsonl`, `sessions`, and resource entries (`skills`, `plugins`, `plugin-state`, `profile-v2`, `themes`, `prompts`). - - Mutable directories (`sessions`, `plugin-state`, `profile-v2`) require real directory links/junctions. Plain copy fallback is not behavior-preserving because it forks session/state by account. - - Mutable files (`config.toml`, `history.jsonl`, and `profile-v2` when file-shaped) should use real symlinks where possible. On Windows filesystems that reject file symlinks, Orca may use an owned fallback copy only if it reconciles launch-home mutations back into the shared environment before preparing any launch home. This covers both direct writes and atomic rename over a symlink. - - `hooks.json` is stricter: if file linking fails, Orca does not silently copy it into the launch home. A copied hook file can change the trusted hook path, which is worse than a missing hook because it can look enabled while Codex rejects it. - - Read-mostly resource entries may use marker-owned copy fallback when links fail. Markers must let Orca refresh/remove only entries it created. - -5. Launch terminals through a stable active home. - - `prepareForCodexLaunch()` returns `codex-runtime-home/active/host/home`, which is an Orca-owned directory symlink/junction to the selected launch home. Account switches atomically repoint this active home. New terminals and existing idle shells therefore keep the same `CODEX_HOME` string while the next `codex` process follows the new selected account. - - Rate-limit fetches continue to use the concrete selected launch home so quota reads are not coupled to a mutable pointer. Old sessions that already point at `codex-runtime-home/home` can continue to exist, but fresh launches no longer share their `auth.json` path. Read-back for refreshed tokens is launch-home scoped; `codex-runtime-home/home/auth.json` is ignored for deciding the active account of fresh host launches. - -6. Keep session and usage aggregation shared. - - Because `sessions` in launch homes links to the shared environment, Codex writes one shared session tree. Existing usage scanning can continue to read `getOrcaManagedCodexHomePath()/sessions`. - -7. Handle Windows and macOS explicitly. - - On Windows, directory links use junctions when possible and file links may fail without Developer Mode. Mutable file fallback therefore requires reconciliation; mutable directory fallback must not silently copy. On macOS/Linux, symlinks should work. The fallback path must preserve behavior, not just tests. - -8. Mirror the active-home contract for WSL. - - WSL launch homes remain per-distro and selected by target. Windows prepares `codex-runtime-home/active/wsl/home` inside the target distro and points it at that distro's selected launch home. WSL terminals receive the Linux path for the active home, not a Windows host path. - -9. Clean up launch-home credentials. - - Every launch home has a `.orca-managed-launch-home` marker. Removing a managed account removes that account's marked host launch home after containment verification. System logout removes only the system launch home's `auth.json`. - -## Data Flow - -- Account switch: - - Persist selected account id in settings. - - Read back refreshed tokens from the previous selected launch auth path only if identity matches. As a compatibility fallback, a matching old shared-home refresh may be persisted to the outgoing account, but never to the incoming account. - - Prepare selected launch home. - - Repoint the active home to the selected launch home. - - Rate-limit fetch runs against selected launch home. - -- New Codex terminal: - - Main resolves target from PTY shell/cwd. - - Host target calls `prepareForCodexLaunch()`. - - Shared environment home is synced. - - Selected launch home is materialized. - - Active home points to selected launch home. - - `CODEX_HOME` and `ORCA_CODEX_HOME` point to active home. - -- Old live Codex process: - - Continues writing whichever real home Codex canonicalized at startup. - - If it was launched before this change from the shared home, the read-back guard still prevents managed-account corruption. - - Fresh launches do not read the old process's shared `auth.json`. - -- Old idle shell: - - Keeps the stable active-home `CODEX_HOME` environment it was spawned with. - - If the user later runs `codex` inside that shell, it follows the repointed active home and uses the current account. - - Pre-change shells that still point directly at a concrete launch home or the old shared home remain outside this guarantee. - -```text -native ~/.codex - user resources/config source only - -Orca/codex-runtime-home/home - shared config.toml, hooks.json, sessions, resources - -Orca/codex-runtime-home/active/host/home - symlink/junction to the selected launch home - -Orca/codex-runtime-home/launch/host/account-a/home - auth.json real file for account A - config.toml link/copy to shared home - hooks.json link to shared home when supported - sessions/ directory link/junction to shared home - skills/plugins/... link or owned copy fallback to shared home -``` - -## Edge Cases - -- Old shared-home Codex process writes stale Account A auth after selecting Account B. -- Account A and Account B have the same email but different provider/workspace ids. -- Two managed accounts have ambiguous identity fields. -- Managed account auth is missing or corrupt. -- System default logout removes `~/.codex/auth.json`. -- System default auth refreshes outside Orca. -- Symlink creation fails on Windows for file links. -- A shared config/resource entry is deleted after a launch-home link or fallback copy exists. -- Launch-home fallback copy exists but the user edited it manually. -- Codex creates a new top-level file in a launch home that Orca does not know is shared state. -- Daemon reattach points at a pre-change PTY with a concrete old `CODEX_HOME`. -- Pre-change idle shell opened as Account A later runs Codex after switching to Account B. -- WSL shell launched from Windows must not receive a host launch-home path. -- macOS/Linux symlinks must use relative/absolute targets without Windows junction behavior. -- Codex atomically rewrites `config.toml` over a launch-home symlink or fallback copy. -- Account removal leaves copied launch-home credentials behind. -- Active-home symlink/junction replacement fails and Orca falls back to a concrete selected launch home for that launch. - -## Test Plan - -- Unit: host managed Account A and Account B receive different selected launch-home paths. -- Unit: Account A launch home and Account B launch home share `config.toml`, `hooks.json`, resources, and `sessions` with the shared environment home. -- Unit: stale shared runtime `auth.json` from Account A does not affect Account B launch home after selecting Account B. -- Unit: refreshed tokens written in Account A launch home read back to Account A, then Account B launch home remains Account B. -- Unit: system default launch home mirrors system auth and handles logout. -- Unit: Windows link fallback creates owned copies and never overwrites user-edited launch-home files. -- Unit: mutable file fallback reconciles an Account A launch-home `config.toml` mutation before Account B launch prep. -- Unit: atomic rename over a launch-home `config.toml` symlink/fallback is reconciled back to shared config. -- Unit: account removal deletes the marked account launch home auth. -- Unit: removing an account that never launched does not create a new empty launch-home directory. -- Unit: WSL target behavior and Windows WSL path stripping remain unchanged. -- Unit: host and WSL `prepareForCodexLaunch()` return active homes whose links target the selected launch homes. -- Manual: existing shell with stable `CODEX_HOME` starts a new Codex process against the newly selected account after active-home repoint. -- Typecheck: `pnpm run tc:node`, `pnpm run tc:cli`, `pnpm run tc:web`. -- Lint: `pnpm run lint`. -- Electron validation: launch Orca dev on Windows, create fake managed Codex account state through IPC/store where possible, create a terminal, verify visible terminal exists, and verify backing PTY env/session points at an active home that targets the selected launch home. Capture account settings/status bar and terminal screenshots. On macOS, validate through CI/subagent or targeted path/link tests where local hardware is unavailable. - -## UI Quality Bar - -No intentional UI change. Existing account switcher and terminal restart prompt must remain visually unchanged: no clipping, broken menu layout, stale loading state, or misleading account label. - -## Review Screenshots - -1. Settings > Accounts > Codex showing managed accounts and active account state. -2. Status bar Codex account switcher open after account data loads. -3. A terminal created after account selection, visibly ready. - -## Rollout - -1. Add path helpers for shared environment home and selected host launch homes. -2. Add launch-home materialization with link/copy fallback and owned markers. -3. Route host `prepareForCodexLaunch()` through active homes and keep `prepareForRateLimitFetch()` on selected launch homes. -4. Keep existing read-back guard but make it read from the relevant selected launch auth path when possible. -5. Add regression tests for stale auth, shared non-auth entries, fallback copies, and WSL no-regression. -6. Fix the current CLI typecheck include for `codex-config-sync-state.ts`. - -## Lightweight Eng Review - -- Scope: keep the change host-local and auth-isolation-only. Do not fork config/session/resource semantics by account and do not change SSH remote homes. -- Architecture/data flow: `codex-runtime-home/home` remains the shared environment boundary used by config mirror, hook service, session bridge, and usage scanner. `runtime-home-service` owns selected launch-home materialization because it already owns launch preparation and auth read-back. -- Failure modes covered: - - stale old-process writes to shared `auth.json` - - token refresh read-back to wrong account - - file link failures on Windows - - stale owned fallback copies - - missing/corrupt auth files - - WSL host-path leakage - - daemon reattach to old session -- Test coverage required: - - `src/main/codex-accounts/runtime-home-service.test.ts` for selected launch homes, auth isolation, system default, and stale writes - - `src/main/codex/codex-home-paths.test.ts` or new targeted tests for link/copy fallback helpers - - `src/main/ipc/pty.test.ts` for selected launch-home env injection and WSL stripping - - targeted Electron validation for visible account switcher and terminal creation -- Performance/blast radius: launch prep adds a small fixed set of link/copy checks per Codex launch. Avoid recursive full-home copying. Fallback copies are limited to known entries and marker-owned refreshes. -- UI quality bar: not UI-visible; existing Settings/status-bar account controls must not regress. -- Required review screenshots: - 1. Codex account settings state - 2. Status-bar switcher state - 3. Terminal after selected-account launch -- Residual risks: - - A pre-change live Codex process launched from the old shared home can still mutate the old shared `auth.json`; fresh launches should no longer consume it. - - A pre-change idle shell that already has a concrete old `CODEX_HOME` cannot be repaired by repointing the active home. - - Windows junction replacement semantics still need native Windows validation; failure falls back to concrete launch homes, preserving correctness but losing hot-swap for that launch. - - Unknown Codex-created top-level launch-home files are not adopted into shared state until Orca explicitly classifies them. This avoids crashing or copying locked live sqlite files, but it means "everything except auth" is guaranteed only for the known shared entries above. - - If Codex stores account-sensitive data outside `auth.json`, sharing sessions/state may need a later narrower exception. diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 55e288fe5..2abb4752b 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1,6 +1,5 @@ /* eslint-disable max-lines -- test suite covers snapshot, migration, auth materialization, and error-resilience scenarios */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createHash } from 'node:crypto' import { chmodSync, existsSync, @@ -11,6 +10,7 @@ import { readFileSync, rmSync, statSync, + symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -155,48 +155,10 @@ function getRuntimeCodexAuthPath(): string { return join(getRuntimeCodexHomePath(), 'auth.json') } -function getSystemLaunchCodexHomePath(): string { - return join(testState.userDataDir, 'codex-runtime-home', 'launch', 'host', 'system', 'home') -} - -function getActiveHostCodexHomePath(): string { +function getLegacyActiveHostCodexHomePath(): string { return join(testState.userDataDir, 'codex-runtime-home', 'active', 'host', 'home') } -function getHostLaunchCodexHomePath(accountId: string | null): string { - const segment = - accountId === null - ? 'system' - : `account-${createHash('sha256').update(accountId).digest('hex').slice(0, 32)}` - return join(testState.userDataDir, 'codex-runtime-home', 'launch', 'host', segment, 'home') -} - -function getWslRuntimeCodexHomePath(wslHome: string): string { - return join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') -} - -function getActiveWslCodexHomePath(wslHome: string): string { - return join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'active', 'wsl', 'home') -} - -function getWslLaunchCodexHomePath(wslHome: string, accountId: string | null): string { - const segment = - accountId === null - ? 'system' - : `account-${createHash('sha256').update(accountId).digest('hex').slice(0, 32)}` - return join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'launch', - 'wsl', - segment, - 'home' - ) -} - function normalizeLinkTarget(linkTarget: string): string { return process.platform === 'win32' ? linkTarget.replace(/^\\\\\?\\/, '').toLowerCase() @@ -211,20 +173,6 @@ function expectResourceLinkedOrCopied(targetPath: string, sourcePath: string): v expect(normalizeLinkTarget(readlinkSync(targetPath))).toBe(normalizeLinkTarget(sourcePath)) } -function expectResourceLinked(targetPath: string, sourcePath: string): void { - const targetStat = lstatSync(targetPath) - if (targetStat.isSymbolicLink()) { - expect(normalizeLinkTarget(readlinkSync(targetPath))).toBe(normalizeLinkTarget(sourcePath)) - return - } - const sourceStat = statSync(sourcePath) - expect(sourceStat.isFile()).toBe(true) - expect(targetStat.isFile()).toBe(true) - expect(targetStat.dev).toBe(sourceStat.dev) - expect(targetStat.ino).toBe(sourceStat.ino) - expect(targetStat.nlink).toBeGreaterThan(1) -} - function createStore(settings: GlobalSettings) { return { getSettings: vi.fn(() => settings), @@ -359,6 +307,69 @@ describe('CodexRuntimeHomeService', () => { ).toBe(false) }) + it('repoints legacy active host CODEX_HOME to the shared runtime home on startup', async () => { + const legacyLaunchHomePath = join( + testState.userDataDir, + 'codex-runtime-home', + 'launch', + 'host', + 'account-old', + 'home' + ) + const legacyActiveHomePath = getLegacyActiveHostCodexHomePath() + mkdirSync(legacyLaunchHomePath, { recursive: true }) + mkdirSync(join(legacyActiveHomePath, '..'), { recursive: true }) + symlinkSync( + legacyLaunchHomePath, + legacyActiveHomePath, + process.platform === 'win32' ? 'junction' : undefined + ) + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + '{"account":"managed"}\n' + ) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + ) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + expect(normalizeLinkTarget(readlinkSync(legacyActiveHomePath))).toBe( + normalizeLinkTarget(getRuntimeCodexHomePath()) + ) + expect(readFileSync(join(legacyActiveHomePath, 'auth.json'), 'utf-8')).toBe( + '{"account":"managed"}\n' + ) + }) + + it('does not create a legacy active host pointer for fresh shared-home users', async () => { + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const store = createStore(createSettings()) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + expect(existsSync(getLegacyActiveHostCodexHomePath())).toBe(false) + }) + it('restores the system-default snapshot when no managed account is selected', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') @@ -604,414 +615,14 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(runtimeAuthPath)).toBe(false) }) - it('returns the active home for Codex launch and the selected launch home for rate-limit preparation', async () => { + it('returns the Orca-managed runtime home for Codex launch and rate-limit preparation', async () => { const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - expect(service.prepareForCodexLaunch()).toBe(getActiveHostCodexHomePath()) - expect(service.prepareForRateLimitFetch()).toBe(getSystemLaunchCodexHomePath()) + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) expect(existsSync(getRuntimeCodexHomePath())).toBe(true) - expect(existsSync(getSystemLaunchCodexHomePath())).toBe(true) - expectResourceLinked(getActiveHostCodexHomePath(), getSystemLaunchCodexHomePath()) - }) - - it('uses separate selected host launch homes while sharing non-auth runtime state', async () => { - const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') - const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - writeFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'model = "gpt-5.5"\n', 'utf-8') - mkdirSync(join(getRuntimeCodexHomePath(), 'sessions'), { recursive: true }) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-one', - workspaceLabel: null, - workspaceAccountId: 'acct-one', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-two', - workspaceLabel: null, - workspaceAccountId: 'acct-two', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(service.prepareForCodexLaunch()).toBe(getActiveHostCodexHomePath()) - const launchHome1 = getHostLaunchCodexHomePath('account-1') - settings.activeCodexManagedAccountId = 'account-2' - settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} } - expect(service.prepareForCodexLaunch()).toBe(getActiveHostCodexHomePath()) - const launchHome2 = getHostLaunchCodexHomePath('account-2') - - expect(launchHome1).not.toBe(launchHome2) - expect(launchHome1).toContain(join('codex-runtime-home', 'launch', 'host', 'account-')) - expectResourceLinked(getActiveHostCodexHomePath(), launchHome2) - expect(readFileSync(join(launchHome1!, 'auth.json'), 'utf-8')).toBe(account1Auth) - expect(readFileSync(join(launchHome2!, 'auth.json'), 'utf-8')).toBe(account2Auth) - expectResourceLinkedOrCopied( - join(launchHome1!, 'config.toml'), - join(getRuntimeCodexHomePath(), 'config.toml') - ) - expectResourceLinkedOrCopied( - join(launchHome2!, 'sessions'), - join(getRuntimeCodexHomePath(), 'sessions') - ) - }) - - it('links Codex sqlite runtime state into each selected host launch home', async () => { - const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') - const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const sqliteEntries = [ - 'state_5.sqlite', - 'state_5.sqlite-wal', - 'state_5.sqlite-shm', - 'logs_2.sqlite', - 'logs_2.sqlite-wal', - 'logs_2.sqlite-shm', - 'goals_1.sqlite', - 'memories_1.sqlite' - ] - for (const entryName of sqliteEntries) { - writeFileSync(join(getRuntimeCodexHomePath(), entryName), `${entryName}\n`, 'utf-8') - } - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-one', - workspaceLabel: null, - workspaceAccountId: 'acct-one', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-two', - workspaceLabel: null, - workspaceAccountId: 'acct-two', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(service.prepareForCodexLaunch()).toBe(getActiveHostCodexHomePath()) - const launchHome1 = getHostLaunchCodexHomePath('account-1') - settings.activeCodexManagedAccountId = 'account-2' - settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} } - expect(service.prepareForCodexLaunch()).toBe(getActiveHostCodexHomePath()) - const launchHome2 = getHostLaunchCodexHomePath('account-2') - - expectResourceLinked(getActiveHostCodexHomePath(), launchHome2) - expect(readFileSync(join(launchHome1, 'auth.json'), 'utf-8')).toBe(account1Auth) - expect(readFileSync(join(launchHome2, 'auth.json'), 'utf-8')).toBe(account2Auth) - expect(lstatSync(join(launchHome1, 'auth.json')).isSymbolicLink()).toBe(false) - expect(lstatSync(join(launchHome2, 'auth.json')).isSymbolicLink()).toBe(false) - for (const entryName of sqliteEntries) { - const sharedPath = join(getRuntimeCodexHomePath(), entryName) - expectResourceLinked(join(launchHome1, entryName), sharedPath) - expectResourceLinked(join(launchHome2, entryName), sharedPath) - } - }) - - it('replaces prior launch-home sqlite forks with shared runtime links', async () => { - const accountAuth = createCodexAuthJson('user@example.com', 'acct-1', 'token') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', accountAuth) - writeFileSync(join(getRuntimeCodexHomePath(), 'state_5.sqlite'), 'shared-state\n', 'utf-8') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - const launchHome = service.prepareForCodexLaunch() - rmSync(join(launchHome!, 'state_5.sqlite'), { force: true }) - writeFileSync(join(launchHome!, 'state_5.sqlite'), 'forked-launch-state\n', 'utf-8') - - const refreshedLaunchHome = service.prepareForCodexLaunch() - - expect(refreshedLaunchHome).toBe(launchHome) - expectResourceLinked( - join(refreshedLaunchHome!, 'state_5.sqlite'), - join(getRuntimeCodexHomePath(), 'state_5.sqlite') - ) - expect(readFileSync(join(refreshedLaunchHome!, 'state_5.sqlite'), 'utf-8')).toBe( - 'shared-state\n' - ) - expect(readFileSync(join(refreshedLaunchHome!, 'auth.json'), 'utf-8')).toBe(accountAuth) - }) - - it('prelinks missing sqlite sidecars into the shared runtime home', async () => { - const accountAuth = createCodexAuthJson('user@example.com', 'acct-1', 'token') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', accountAuth) - writeFileSync(join(getRuntimeCodexHomePath(), 'state_5.sqlite'), 'shared-state\n', 'utf-8') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - const launchHome = service.prepareForCodexLaunch() - - expectResourceLinked( - join(launchHome!, 'state_5.sqlite'), - join(getRuntimeCodexHomePath(), 'state_5.sqlite') - ) - expectResourceLinked( - join(launchHome!, 'state_5.sqlite-wal'), - join(getRuntimeCodexHomePath(), 'state_5.sqlite-wal') - ) - expectResourceLinked( - join(launchHome!, 'state_5.sqlite-shm'), - join(getRuntimeCodexHomePath(), 'state_5.sqlite-shm') - ) - expect(existsSync(join(getRuntimeCodexHomePath(), 'state_5.sqlite-wal'))).toBe( - process.platform === 'win32' - ) - expect(existsSync(join(getRuntimeCodexHomePath(), 'state_5.sqlite-shm'))).toBe( - process.platform === 'win32' - ) - expect(readFileSync(join(launchHome!, 'auth.json'), 'utf-8')).toBe(accountAuth) - }) - - it('ignores stale shared auth when preparing a different selected launch home', async () => { - const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') - const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') - const staleSharedAuth = createCodexAuthJson('one@example.com', 'acct-one', 'stale-shared') - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-one', - workspaceLabel: null, - workspaceAccountId: 'acct-one', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-two', - workspaceLabel: null, - workspaceAccountId: 'acct-two', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - service.prepareForCodexLaunch() - writeFileSync(getRuntimeCodexAuthPath(), staleSharedAuth, 'utf-8') - settings.activeCodexManagedAccountId = 'account-2' - settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} } - const launchHome2 = service.prepareForCodexLaunch() - - expect(readFileSync(join(launchHome2!, 'auth.json'), 'utf-8')).toBe(account2Auth) - expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth) - }) - - it('reads refreshed managed tokens back from the selected launch home', async () => { - const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') - const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'refreshed') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - const launchHome = service.prepareForCodexLaunch() - writeFileSync(join(launchHome!, 'auth.json'), refreshedAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(join(launchHome!, 'auth.json'), 'utf-8')).toBe(refreshedAuth) - }) - - it('reconciles launch-home config rewrites before preparing another account', async () => { - const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') - const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - writeFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'model = "gpt-5"\n', 'utf-8') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-one', - workspaceLabel: null, - workspaceAccountId: 'acct-one', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-two', - workspaceLabel: null, - workspaceAccountId: 'acct-two', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - const launchHome1 = service.prepareForCodexLaunch() - const launchConfigPath1 = join(launchHome1!, 'config.toml') - rmSync(launchConfigPath1, { force: true }) - writeFileSync(launchConfigPath1, 'model = "gpt-5.5"\nfast_mode = true\n', 'utf-8') - - settings.activeCodexManagedAccountId = 'account-2' - settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} } - const launchHome2 = service.prepareForCodexLaunch() - - expect(readFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'utf-8')).toBe( - 'model = "gpt-5.5"\nfast_mode = true\n' - ) - expect(readFileSync(join(launchHome2!, 'config.toml'), 'utf-8')).toBe( - 'model = "gpt-5.5"\nfast_mode = true\n' - ) - }) - - it('removes marked launch-home credentials when a managed account is removed', async () => { - const accountAuth = createCodexAuthJson('user@example.com', 'acct-1', 'token') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', accountAuth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - const launchHome = service.prepareForCodexLaunch() - expect(readFileSync(join(launchHome!, 'auth.json'), 'utf-8')).toBe(accountAuth) - - service.removeHostLaunchHomeForAccount('account-1') - - expect(existsSync(launchHome!)).toBe(false) - }) - - it('does not create a launch-home directory when removing an account that never launched', async () => { - const store = createStore(createSettings()) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - service.removeHostLaunchHomeForAccount('never-launched') - - const neverLaunchedSegment = `account-${createHash('sha256') - .update('never-launched') - .digest('hex') - .slice(0, 32)}` - expect( - existsSync( - join(testState.userDataDir, 'codex-runtime-home', 'launch', 'host', neverLaunchedSegment) - ) - ).toBe(false) }) it('mirrors later system Codex config changes before launch', async () => { @@ -1031,28 +642,6 @@ describe('CodexRuntimeHomeService', () => { ) }) - it('keeps Codex TUI config changes across launch preparation when system config is unchanged', async () => { - const systemCodexHome = getSystemCodexHomePath() - mkdirSync(systemCodexHome, { recursive: true }) - writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8') - const store = createStore(createSettings()) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - service.prepareForCodexLaunch() - writeFileSync( - join(getRuntimeCodexHomePath(), 'config.toml'), - ['model = "runtime-model"', 'model_reasoning_effort = "low"', ''].join('\n'), - 'utf-8' - ) - service.prepareForCodexLaunch() - - const runtimeConfig = readFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'utf-8') - expect(runtimeConfig).toContain('model = "runtime-model"') - expect(runtimeConfig).toContain('model_reasoning_effort = "low"') - expect(runtimeConfig).not.toContain('model = "system-model"') - }) - it('links system Codex user resources into the managed runtime home before launch', async () => { const systemCodexHome = getSystemCodexHomePath() mkdirSync(join(systemCodexHome, 'skills', 'review'), { recursive: true }) @@ -1202,25 +791,26 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - const wslRuntimeHomePath = getWslRuntimeCodexHomePath(wslHome) - const wslLaunchHomePath = getWslLaunchCodexHomePath(wslHome, 'account-1') + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"host-system"}\n') - expect(service.prepareForCodexLaunch()).toBe(getActiveHostCodexHomePath()) - expectResourceLinked(getActiveHostCodexHomePath(), getSystemLaunchCodexHomePath()) + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - getActiveWslCodexHomePath(wslHome) - ) - expectResourceLinked(getActiveWslCodexHomePath(wslHome), wslLaunchHomePath) - expect(readFileSync(join(wslLaunchHomePath, 'auth.json'), 'utf-8')).toBe( - '{"account":"wsl"}\n' + wslRuntimeHomePath ) expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe( '{"account":"wsl"}\n' ) - expect(service.prepareForRateLimitFetch()).toBe(getSystemLaunchCodexHomePath()) + expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslLaunchHomePath + wslRuntimeHomePath ) } finally { if (originalPlatform) { @@ -1273,17 +863,23 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - const wslLaunchHomePath = getWslLaunchCodexHomePath(wslHome, null) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - getActiveWslCodexHomePath(wslHome) + wslRuntimeHomePath ) - expectResourceLinked(getActiveWslCodexHomePath(wslHome), wslLaunchHomePath) expect(store.updateSettings).toHaveBeenCalledWith({ activeCodexManagedAccountId: null, activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } }) - expect(readFileSync(join(wslLaunchHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -1291,7 +887,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('switches WSL accounts by using separate selected WSL launch homes', async () => { + it('switches WSL accounts by rewriting one stable WSL runtime home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -1344,13 +940,16 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - const wslRuntimeHomePath = getWslRuntimeCodexHomePath(wslHome) - const firstLaunchHomePath = getWslLaunchCodexHomePath(wslHome, 'account-1') - const secondLaunchHomePath = getWslLaunchCodexHomePath(wslHome, 'account-2') + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) - expect(service.prepareForCodexLaunch(target)).toBe(getActiveWslCodexHomePath(wslHome)) - expectResourceLinked(getActiveWslCodexHomePath(wslHome), firstLaunchHomePath) - expect(readFileSync(join(firstLaunchHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) store.updateSettings({ @@ -1358,10 +957,7 @@ describe('CodexRuntimeHomeService', () => { }) service.syncForCurrentSelection(target) - expect(service.prepareForCodexLaunch(target)).toBe(getActiveWslCodexHomePath(wslHome)) - expectResourceLinked(getActiveWslCodexHomePath(wslHome), secondLaunchHomePath) - expect(readFileSync(join(secondLaunchHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) - expect(readFileSync(join(firstLaunchHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) } finally { if (originalPlatform) { @@ -1370,178 +966,6 @@ describe('CodexRuntimeHomeService', () => { } }) - it('shares WSL non-auth runtime state across selected launch homes', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const wslHome = join(testState.userDataDir, 'wsl-home') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome - })) - const firstAuth = createCodexAuthJson('first@example.com', 'acct-first', 'first-token') - const secondAuth = createCodexAuthJson('second@example.com', 'acct-second', 'second-token') - const firstManagedHomePath = createManagedAuth(testState.userDataDir, 'account-1', firstAuth) - const secondManagedHomePath = createManagedAuth(testState.userDataDir, 'account-2', secondAuth) - const wslRuntimeHomePath = getWslRuntimeCodexHomePath(wslHome) - mkdirSync(join(wslRuntimeHomePath, 'sessions'), { recursive: true }) - writeFileSync(join(wslRuntimeHomePath, 'config.toml'), 'model = "gpt-5"\n', 'utf-8') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'first@example.com', - managedHomePath: firstManagedHomePath, - managedHomeRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', - providerAccountId: 'acct-first', - workspaceLabel: null, - workspaceAccountId: 'acct-first', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'second@example.com', - managedHomePath: secondManagedHomePath, - managedHomeRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-2/home', - providerAccountId: 'acct-second', - workspaceLabel: null, - workspaceAccountId: 'acct-second', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: null, - activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } - }) - const store = createStore(settings) - - try { - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - - const firstLaunchHomePath = service.prepareForCodexLaunch(target) - const firstConfigPath = join(firstLaunchHomePath!, 'config.toml') - rmSync(firstConfigPath, { force: true }) - writeFileSync(firstConfigPath, 'model = "gpt-5.5"\nfast_mode = true\n', 'utf-8') - - settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: { Ubuntu: 'account-2' } } - const secondLaunchHomePath = service.prepareForCodexLaunch(target) - - expect(readFileSync(join(wslRuntimeHomePath, 'config.toml'), 'utf-8')).toBe( - 'model = "gpt-5.5"\nfast_mode = true\n' - ) - expect(readFileSync(join(secondLaunchHomePath!, 'config.toml'), 'utf-8')).toBe( - 'model = "gpt-5.5"\nfast_mode = true\n' - ) - expectResourceLinkedOrCopied( - join(secondLaunchHomePath!, 'sessions'), - join(wslRuntimeHomePath, 'sessions') - ) - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - } - }) - - it('links WSL sqlite runtime state into each selected WSL launch home', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const wslHome = join(testState.userDataDir, 'wsl-home') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome - })) - const firstAuth = createCodexAuthJson('first@example.com', 'acct-first', 'first-token') - const secondAuth = createCodexAuthJson('second@example.com', 'acct-second', 'second-token') - const firstManagedHomePath = createManagedAuth(testState.userDataDir, 'account-1', firstAuth) - const secondManagedHomePath = createManagedAuth(testState.userDataDir, 'account-2', secondAuth) - const wslRuntimeHomePath = getWslRuntimeCodexHomePath(wslHome) - const sqliteEntries = [ - 'state_5.sqlite', - 'state_5.sqlite-wal', - 'state_5.sqlite-shm', - 'logs_2.sqlite', - 'logs_2.sqlite-wal', - 'logs_2.sqlite-shm', - 'goals_1.sqlite', - 'memories_1.sqlite' - ] - mkdirSync(wslRuntimeHomePath, { recursive: true }) - for (const entryName of sqliteEntries) { - writeFileSync(join(wslRuntimeHomePath, entryName), `${entryName}\n`, 'utf-8') - } - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'first@example.com', - managedHomePath: firstManagedHomePath, - managedHomeRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', - providerAccountId: 'acct-first', - workspaceLabel: null, - workspaceAccountId: 'acct-first', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'second@example.com', - managedHomePath: secondManagedHomePath, - managedHomeRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-2/home', - providerAccountId: 'acct-second', - workspaceLabel: null, - workspaceAccountId: 'acct-second', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: null, - activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } - }) - const store = createStore(settings) - - try { - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - - expect(service.prepareForCodexLaunch(target)).toBe(getActiveWslCodexHomePath(wslHome)) - const firstLaunchHomePath = getWslLaunchCodexHomePath(wslHome, 'account-1') - settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: { Ubuntu: 'account-2' } } - expect(service.prepareForCodexLaunch(target)).toBe(getActiveWslCodexHomePath(wslHome)) - const secondLaunchHomePath = getWslLaunchCodexHomePath(wslHome, 'account-2') - - expectResourceLinked(getActiveWslCodexHomePath(wslHome), secondLaunchHomePath) - expect(readFileSync(join(firstLaunchHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) - expect(readFileSync(join(secondLaunchHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) - expect(lstatSync(join(firstLaunchHomePath, 'auth.json')).isSymbolicLink()).toBe(false) - expect(lstatSync(join(secondLaunchHomePath, 'auth.json')).isSymbolicLink()).toBe(false) - for (const entryName of sqliteEntries) { - const sharedPath = join(wslRuntimeHomePath, entryName) - expectResourceLinked(join(firstLaunchHomePath, entryName), sharedPath) - expectResourceLinked(join(secondLaunchHomePath, entryName), sharedPath) - } - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - } - }) - it('does not use host auth baseline to accept stale WSL runtime auth', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -1569,8 +993,14 @@ describe('CodexRuntimeHomeService', () => { 'wsl-account', wslManagedAuth ) - const wslRuntimeHomePath = getWslRuntimeCodexHomePath(wslHome) - const wslLaunchHomePath = getWslLaunchCodexHomePath(wslHome, 'wsl-account') + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) mkdirSync(wslRuntimeHomePath, { recursive: true }) writeFileSync(join(wslRuntimeHomePath, 'auth.json'), staleWslRuntimeAuth, 'utf-8') const store = createStore( @@ -1616,11 +1046,10 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(hostAuth) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - getActiveWslCodexHomePath(wslHome) + wslRuntimeHomePath ) - expectResourceLinked(getActiveWslCodexHomePath(wslHome), wslLaunchHomePath) expect(readFileSync(join(wslManagedHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) - expect(readFileSync(join(wslLaunchHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -1642,8 +1071,15 @@ describe('CodexRuntimeHomeService', () => { const reauthedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'reauthed', 2_000) const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) const managedAuthPath = join(managedHomePath, 'auth.json') - const wslLaunchHomePath = getWslLaunchCodexHomePath(wslHome, 'account-1') - const runtimeAuthPath = join(wslLaunchHomePath, 'auth.json') + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json') const store = createStore( createSettings({ codexManagedAccounts: [ @@ -1673,8 +1109,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - expect(service.prepareForCodexLaunch(target)).toBe(getActiveWslCodexHomePath(wslHome)) - expectResourceLinked(getActiveWslCodexHomePath(wslHome), wslLaunchHomePath) + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') writeFileSync(managedAuthPath, reauthedAuth, 'utf-8') @@ -1690,70 +1125,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('reads WSL managed token refreshes from selected launch homes after app restart', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const wslHome = join(testState.userDataDir, 'wsl-home') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome - })) - const managedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed-old', 1_000) - const refreshedAuth = createCodexAuthJson( - 'wsl@example.com', - 'acct-wsl', - 'launch-refreshed', - 2_000 - ) - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const wslLaunchHomePath = getWslLaunchCodexHomePath(wslHome, 'account-1') - mkdirSync(wslLaunchHomePath, { recursive: true }) - writeFileSync(join(wslLaunchHomePath, 'auth.json'), refreshedAuth, 'utf-8') - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'wsl@example.com', - managedHomePath, - managedHomeRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', - providerAccountId: 'acct-wsl', - workspaceLabel: null, - workspaceAccountId: 'acct-wsl', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'account-1' } - } - }) - ) - - try { - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - - expect(service.prepareForCodexLaunch(target)).toBe(getActiveWslCodexHomePath(wslHome)) - expectResourceLinked(getActiveWslCodexHomePath(wslHome), wslLaunchHomePath) - expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(join(wslLaunchHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(join(getWslRuntimeCodexHomePath(wslHome), 'auth.json'), 'utf-8')).toBe( - refreshedAuth - ) - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - } - }) - - it('uses the selected WSL launch home for WSL system-default rate-limit fetches', async () => { + it('uses the stable WSL runtime home for WSL system-default rate-limit fetches', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -1773,7 +1145,7 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - getWslLaunchCodexHomePath(wslHome, null) + join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') ) } finally { if (originalPlatform) { @@ -1794,7 +1166,15 @@ describe('CodexRuntimeHomeService', () => { const debianAuth = createCodexAuthJson('debian@example.com', 'acct-debian', 'debian-token') const ubuntuHomePath = createManagedAuth(testState.userDataDir, 'ubuntu-account', ubuntuAuth) const debianHomePath = createManagedAuth(testState.userDataDir, 'debian-account', debianAuth) - const runtimeAuthPath = join(getWslLaunchCodexHomePath(wslHome, 'ubuntu-account'), 'auth.json') + const runtimeAuthPath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home', + 'auth.json' + ) const store = createStore( createSettings({ codexManagedAccounts: [ @@ -1839,7 +1219,7 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toBe( - getWslLaunchCodexHomePath(wslHome, 'ubuntu-account') + join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') ) expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(ubuntuAuth) } finally { @@ -1849,78 +1229,6 @@ describe('CodexRuntimeHomeService', () => { } }) - it('preserves WSL system-default refreshes before switching to a managed WSL account', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const wslHome = join(testState.userDataDir, 'wsl-home') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome - })) - const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-old', 1_000) - const refreshedSystemAuth = createCodexAuthJson( - 'system@example.com', - 'acct-system', - 'system-refreshed', - 2_000 - ) - const managedAuth = createCodexAuthJson('managed@example.com', 'acct-managed', 'managed', 1_000) - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const systemCodexHomePath = join(wslHome, '.codex') - mkdirSync(systemCodexHomePath, { recursive: true }) - writeFileSync(join(systemCodexHomePath, 'auth.json'), systemAuth, 'utf-8') - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'managed@example.com', - managedHomePath, - managedHomeRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', - providerAccountId: 'acct-managed', - workspaceLabel: null, - workspaceAccountId: 'acct-managed', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: null, - activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } - }) - ) - - try { - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - const systemLaunchHomePath = getWslLaunchCodexHomePath(wslHome, null) - const managedLaunchHomePath = getWslLaunchCodexHomePath(wslHome, 'account-1') - - expect(service.prepareForCodexLaunch(target)).toBe(getActiveWslCodexHomePath(wslHome)) - expectResourceLinked(getActiveWslCodexHomePath(wslHome), systemLaunchHomePath) - writeFileSync(join(systemLaunchHomePath, 'auth.json'), refreshedSystemAuth, 'utf-8') - - store.updateSettings({ - activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } - }) - - expect(service.prepareForCodexLaunch(target)).toBe(getActiveWslCodexHomePath(wslHome)) - expectResourceLinked(getActiveWslCodexHomePath(wslHome), managedLaunchHomePath) - expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe( - refreshedSystemAuth - ) - expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth) - expect(readFileSync(join(managedLaunchHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth) - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - } - }) - it('does not write WSL system-default auth into managed accounts', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -1966,13 +1274,20 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - const wslLaunchHomePath = getWslLaunchCodexHomePath(wslHome, null) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslLaunchHomePath + wslRuntimeHomePath ) expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth) - expect(readFileSync(join(wslLaunchHomePath, 'auth.json'), 'utf-8')).toBe(systemDefaultAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemDefaultAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -2009,16 +1324,21 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - const wslLaunchHomePath = getWslLaunchCodexHomePath(wslHome, null) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) - expect(service.prepareForCodexLaunch(target)).toBe(getActiveWslCodexHomePath(wslHome)) - expectResourceLinked(getActiveWslCodexHomePath(wslHome), wslLaunchHomePath) - writeFileSync(join(wslLaunchHomePath, 'auth.json'), refreshedAuth, 'utf-8') + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + writeFileSync(join(wslRuntimeHomePath, 'auth.json'), refreshedAuth, 'utf-8') - expect(service.prepareForCodexLaunch(target)).toBe(getActiveWslCodexHomePath(wslHome)) - expectResourceLinked(getActiveWslCodexHomePath(wslHome), wslLaunchHomePath) + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(join(wslLaunchHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -2042,8 +1362,14 @@ describe('CodexRuntimeHomeService', () => { 2_000 ) const systemCodexHomePath = join(wslHome, '.codex') - const wslRuntimeHomePath = getWslRuntimeCodexHomePath(wslHome) - const wslLaunchHomePath = getWslLaunchCodexHomePath(wslHome, null) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) mkdirSync(systemCodexHomePath, { recursive: true }) mkdirSync(wslRuntimeHomePath, { recursive: true }) writeFileSync(join(systemCodexHomePath, 'auth.json'), systemAuth, 'utf-8') @@ -2060,10 +1386,9 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - expect(service.prepareForCodexLaunch(target)).toBe(getActiveWslCodexHomePath(wslHome)) - expectResourceLinked(getActiveWslCodexHomePath(wslHome), wslLaunchHomePath) + expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(join(wslLaunchHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -2130,31 +1455,6 @@ describe('CodexRuntimeHomeService', () => { ).toEqual({ authJson: refreshedAuth }) }) - it('reads back system-default token refreshes from the selected launch home', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-old') - const refreshedAuth = createCodexAuthJson( - 'system@example.com', - 'acct-system', - 'system-launch-refreshed' - ) - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings()) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - const launchHome = service.prepareForCodexLaunch() - - writeFileSync(join(launchHome!, 'auth.json'), refreshedAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(join(getSystemLaunchCodexHomePath(), 'auth.json'), 'utf-8')).toBe( - refreshedAuth - ) - }) - it('reads back system-default token refreshes after restart when the snapshot proves the baseline', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-old') @@ -2725,13 +2025,13 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - // An older account-1 Codex process refreshed the legacy shared runtime - // file after Orca selected account-2. Fresh launch homes must not route - // that stale shared file into any managed account. + // An older account-1 Codex process refreshed the shared runtime file after + // Orca selected account-2. Persist the refresh to account-1, then restore + // the selected account in runtime CODEX_HOME. writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') service.syncForCurrentSelection() - expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(account1Auth) + expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(account1RefreshedAuth) expect(readFileSync(managedAuthPath2, 'utf-8')).toBe(account2Auth) expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) }) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index f6dd7f4a0..9dbca2a6a 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -7,13 +7,29 @@ import { copyFileSync, existsSync, chmodSync, + lstatSync, mkdirSync, + readlinkSync, readdirSync, readFileSync, + renameSync, + rmdirSync, rmSync, - statSync + statSync, + symlinkSync, + unlinkSync } from 'node:fs' -import { dirname, extname, join, parse, relative, win32 as pathWin32 } from 'node:path' +import { execFileSync } from 'node:child_process' +import { + dirname, + extname, + isAbsolute, + join, + parse, + relative, + resolve, + win32 as pathWin32 +} from 'node:path' import { app } from 'electron' import type { CodexManagedAccount } from '../../shared/types' import type { Store } from '../persistence' @@ -23,19 +39,8 @@ import { getSystemCodexHomePath, syncSystemCodexResourcesIntoManagedHome } from '../codex/codex-home-paths' -import { - ensureOrcaCodexLaunchHome, - ensureScopedCodexLaunchHome, - materializeOrcaCodexActiveHome, - materializeOrcaCodexLaunchHome, - materializeScopedCodexLaunchHome, - pointActiveCodexHomeAtLaunchHome, - removeOrcaCodexLaunchHome, - removeScopedCodexLaunchHome -} from '../codex/codex-launch-home-paths' import { syncSystemCodexSessionsIntoManagedHome } from '../codex/codex-session-bridge' import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' -import { trustCodexLaunchHomeHooks } from '../codex/hook-service' import { parseWslUncPath } from '../../shared/wsl-paths' import { getSelectedCodexAccountIdForTarget, @@ -87,19 +92,18 @@ export class CodexRuntimeHomeService { // login (e.g. `codex auth login`) overwrote it — so Orca adopts the file as // the new system default instead of restoring a stale snapshot. private lastWrittenAuthJson: string | null = null - private readonly lastWrittenHostAuthJsonBySelection = new Map() // Why: WSL terminals have their own stable runtime homes per distro. They // cannot share the host baseline or host sync can make stale WSL auth look // newer than managed storage. - private readonly lastWrittenWslAuthJsonBySelection = new Map() + private readonly lastWrittenWslAuthJsonByDistro = new Map() private readonly lastSyncedWslAccountIdByDistro = new Map() private skipNextReadBackForAccountId: string | null = null constructor(private readonly store: Store) { this.safeMigrateLegacyManagedState() + this.safeMigrateLegacyActiveHomePointer() this.initializeLastSyncedState() this.safeSyncForCurrentSelection() - this.safeRefreshCurrentHostActiveHome() } private initializeLastSyncedState(): void { @@ -119,16 +123,16 @@ export class CodexRuntimeHomeService { prepareForCodexLaunch(target?: CodexAccountSelectionTarget): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - const launchHomePath = + return ( this.syncWslRuntimeForCurrentSelection(wslTarget) ?? this.getWslSystemCodexHomePath(wslTarget) - return this.pointWslActiveHomeForLaunch(wslTarget, launchHomePath) + ) } this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() syncSystemCodexSessionsIntoManagedHome() - return this.materializeCurrentHostActiveHome() + return this.getRuntimeHomePath() } private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { @@ -154,30 +158,7 @@ export class CodexRuntimeHomeService { this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() - return this.materializeCurrentHostLaunchHome() - } - - refreshCurrentHostActiveHome(): string | null { - try { - syncSystemCodexResourcesIntoManagedHome() - syncSystemConfigIntoManagedCodexHome() - syncSystemCodexSessionsIntoManagedHome() - return this.materializeCurrentHostActiveHome() - } catch (error) { - console.warn('[codex-runtime-home] Failed to refresh host launch home:', error) - return null - } - } - - refreshCurrentLaunchHome(target?: CodexAccountSelectionTarget): string | null { - if (target?.runtime === 'wsl') { - const wslTarget = this.resolveWslDefaultTarget(target) - const launchHomePath = - this.syncWslRuntimeForCurrentSelection(wslTarget) ?? - this.getWslSystemCodexHomePath(wslTarget) - return this.pointWslActiveHomeForLaunch(wslTarget, launchHomePath) - } - return this.refreshCurrentHostActiveHome() + return this.getRuntimeHomePath() } syncForCurrentSelection(target?: CodexAccountSelectionTarget): void { @@ -213,7 +194,6 @@ export class CodexRuntimeHomeService { } this.lastSyncedAccountId = null this.lastWrittenAuthJson = null - this.setLastWrittenHostAuthJson(null, null) this.skipNextReadBackForAccountId = null return } @@ -326,54 +306,13 @@ export class CodexRuntimeHomeService { ): void { if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) { this.lastWrittenAuthJson = null - this.setLastWrittenHostAuthJson(accountId, null) } this.skipNextReadBackForAccountId = accountId } - removeHostLaunchHomeForAccount(accountId: string): void { - removeOrcaCodexLaunchHome(accountId) - this.lastWrittenHostAuthJsonBySelection.delete(this.getHostLaunchSelectionKey(accountId)) - } - - removeLaunchHomeForAccount(account: CodexManagedAccount): void { - const distro = this.getWslDistroForAccount(account) - if (distro) { - const launchRootPath = this.getWslLaunchRootPath(distro) - if (launchRootPath) { - removeScopedCodexLaunchHome(launchRootPath, account.id) - } - this.lastWrittenWslAuthJsonBySelection.delete( - this.getWslLaunchSelectionKey(distro, account.id) - ) - return - } - this.removeHostLaunchHomeForAccount(account.id) - } - private readBackRefreshedTokens(options: { updateLastWrittenAuthJson: boolean }): CodexReadBackResult { - const accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host - const launchResult = this.readBackRefreshedTokensFromPath( - this.getHostLaunchAuthPath(accountId), - { - ...options, - lastWrittenAuthJson: this.getLastWrittenHostAuthJson(accountId), - setLastWrittenAuthJson: (contents) => { - this.setLastWrittenHostAuthJson(accountId, contents) - } - } - ) - if (launchResult !== 'unchanged') { - return launchResult - } - if (accountId !== null) { - return this.readBackRefreshedTokensFromPath(this.getRuntimeAuthPath(), { - ...options, - expectedAccountId: accountId - }) - } return this.readBackRefreshedTokensFromPath(this.getRuntimeAuthPath(), options) } @@ -441,26 +380,8 @@ export class CodexRuntimeHomeService { account: CodexManagedAccount, options: { updateLastWrittenAuthJson: boolean } ): CodexReadBackResult { - const launchResult = this.readBackRefreshedTokensFromPath( - this.getHostLaunchAuthPath(account.id), - { - ...options, - lastWrittenAuthJson: this.getLastWrittenHostAuthJson(account.id), - setLastWrittenAuthJson: (contents) => { - this.setLastWrittenHostAuthJson(account.id, contents) - }, - expectedAccountId: account.id - } - ) - if (launchResult !== 'unchanged') { - return launchResult - } return this.readBackRefreshedTokensFromPath(this.getRuntimeAuthPath(), { ...options, - lastWrittenAuthJson: this.lastWrittenAuthJson, - setLastWrittenAuthJson: (contents) => { - this.lastWrittenAuthJson = contents - }, expectedAccountId: account.id }) } @@ -473,14 +394,6 @@ export class CodexRuntimeHomeService { } } - private safeRefreshCurrentHostActiveHome(): void { - try { - this.refreshCurrentHostActiveHome() - } catch (error) { - console.warn('[codex-runtime-home] Failed to refresh active host Codex home:', error) - } - } - private getActiveAccount( accounts: CodexManagedAccount[], activeAccountId: string | null @@ -521,36 +434,30 @@ export class CodexRuntimeHomeService { if (!runtimeHomePath) { return null } - const launchRootPath = this.getWslLaunchRootPathFromRuntimeHome(runtimeHomePath) mkdirSync(runtimeHomePath, { recursive: true }) + this.safeMigrateLegacyWslActiveHomePointer(distro, runtimeHomePath) this.seedWslRuntimeHome(runtimeHomePath, activeAccount, distro) const runtimeAuthPath = join(runtimeHomePath, 'auth.json') - const hadPreviousWslSelection = this.lastSyncedWslAccountIdByDistro.has(distro) - const previousWslAccountId = hadPreviousWslSelection - ? (this.lastSyncedWslAccountIdByDistro.get(distro) ?? null) - : null - if (activeAccount && hadPreviousWslSelection && previousWslAccountId === null) { - this.preserveWslSystemDefaultRefresh(distro, launchRootPath, runtimeAuthPath) - } - const readBackAccountId = - previousWslAccountId ?? (!hadPreviousWslSelection ? activeAccount?.id : null) - if (readBackAccountId) { - if (this.skipNextReadBackForAccountId === readBackAccountId) { + const previousWslAccountId = this.lastSyncedWslAccountIdByDistro.get(distro) ?? null + if (previousWslAccountId) { + if (this.skipNextReadBackForAccountId === previousWslAccountId) { this.skipNextReadBackForAccountId = null } else { const previousWslAccount = this.getActiveAccount( settings.codexManagedAccounts, - readBackAccountId + previousWslAccountId ) if (previousWslAccount) { - this.readBackWslManagedAccountRefresh( - distro, - launchRootPath, - runtimeAuthPath, - previousWslAccount - ) + this.readBackRefreshedTokensFromPath(runtimeAuthPath, { + updateLastWrittenAuthJson: true, + lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, + setLastWrittenAuthJson: (contents) => { + this.lastWrittenWslAuthJsonByDistro.set(distro, contents) + }, + expectedAccountId: previousWslAccount.id + }) } } } @@ -559,13 +466,9 @@ export class CodexRuntimeHomeService { if (activeAccount && activeAuthPath && existsSync(activeAuthPath)) { const activeAuth = readFileSync(activeAuthPath, 'utf-8') this.writeRuntimeAuthAtPath(runtimeAuthPath, activeAuth) - this.writeRuntimeAuthAtPath( - this.getWslLaunchAuthPath(launchRootPath, activeAccount.id), - activeAuth - ) - this.setLastWrittenWslAuthJson(distro, activeAccount.id, activeAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, activeAuth) this.lastSyncedWslAccountIdByDistro.set(distro, activeAccount.id) - return materializeScopedCodexLaunchHome(runtimeHomePath, launchRootPath, activeAccount.id) + return runtimeHomePath } if (activeAccount && activeAuthPath) { console.warn( @@ -584,40 +487,36 @@ export class CodexRuntimeHomeService { const systemAuthPath = this.getWslSystemCodexAuthPath({ runtime: 'wsl', wslDistro: distro }) if (systemAuthPath && existsSync(systemAuthPath)) { const systemAuth = readFileSync(systemAuthPath, 'utf-8') - const mirroredSystemDefaultAuth = this.getLastWrittenWslAuthJson(distro, null) + const mirroredSystemDefaultAuth = this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null const runtimeAuth = existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null - const systemLaunchAuthPath = this.getWslLaunchAuthPath(launchRootPath, null) - const launchAuth = existsSync(systemLaunchAuthPath) - ? readFileSync(systemLaunchAuthPath, 'utf-8') - : null - const refreshedAuth = this.selectWslSystemDefaultRefreshCandidate({ - launchAuth, - runtimeAuth, - systemAuth, - mirroredSystemDefaultAuth - }) - if (refreshedAuth) { - this.writeRuntimeAuthAtPath(systemAuthPath, refreshedAuth) - this.writeRuntimeAuthAtPath(runtimeAuthPath, refreshedAuth) - this.writeRuntimeAuthAtPath(systemLaunchAuthPath, refreshedAuth) - this.setLastWrittenWslAuthJson(distro, null, refreshedAuth) + if ( + runtimeAuth !== null && + runtimeAuth !== systemAuth && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemAuth) && + ((mirroredSystemDefaultAuth !== null && systemAuth === mirroredSystemDefaultAuth) || + (mirroredSystemDefaultAuth === null && + this.runtimeAuthIsFresher(runtimeAuth, systemAuth))) + ) { + // Why: WSL runtime homes are per-distro and their in-memory baseline is + // lost on app restart. A same-identity fresher runtime auth is a Codex + // token refresh and should be copied back before we mirror ~/.codex. + this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth) this.lastSyncedWslAccountIdByDistro.set(distro, null) - return materializeScopedCodexLaunchHome(runtimeHomePath, launchRootPath, null) + return runtimeHomePath } this.writeRuntimeAuthAtPath(runtimeAuthPath, systemAuth) - this.writeRuntimeAuthAtPath(systemLaunchAuthPath, systemAuth) - this.setLastWrittenWslAuthJson(distro, null, systemAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, systemAuth) this.lastSyncedWslAccountIdByDistro.set(distro, null) - return materializeScopedCodexLaunchHome(runtimeHomePath, launchRootPath, null) + return runtimeHomePath } rmSync(runtimeAuthPath, { force: true }) - rmSync(this.getWslLaunchAuthPath(launchRootPath, null), { force: true }) - this.setLastWrittenWslAuthJson(distro, null, null) + this.lastWrittenWslAuthJsonByDistro.set(distro, null) this.lastSyncedWslAccountIdByDistro.set(distro, null) - return materializeScopedCodexLaunchHome(runtimeHomePath, launchRootPath, null) + return runtimeHomePath } private getWslRuntimeHomePath(distro: string): string | null { @@ -627,130 +526,47 @@ export class CodexRuntimeHomeService { : null } - private getWslLaunchRootPath(distro: string): string | null { - const runtimeHomePath = this.getWslRuntimeHomePath(distro) - return runtimeHomePath ? this.getWslLaunchRootPathFromRuntimeHome(runtimeHomePath) : null - } - - private getWslLaunchRootPathFromRuntimeHome(runtimeHomePath: string): string { - return this.joinWslPath(dirname(runtimeHomePath), 'launch', 'wsl') - } - - private getWslLaunchAuthPath(launchRootPath: string, accountId: string | null): string { - return join(ensureScopedCodexLaunchHome(launchRootPath, accountId), 'auth.json') - } - - private getWslDistroForAccount(account: CodexManagedAccount): string | null { - if (!this.getWslManagedHomePath(account)) { - return null - } - return account.wslDistro ?? parseWslUncPath(account.managedHomePath)?.distro ?? null - } - - private getWslLaunchSelectionKey(distro: string, accountId: string | null): string { - return `${distro}\0${accountId ?? 'system'}` - } - - private getLastWrittenWslAuthJson(distro: string, accountId: string | null): string | null { - return ( - this.lastWrittenWslAuthJsonBySelection.get( - this.getWslLaunchSelectionKey(distro, accountId) - ) ?? null - ) - } - - private setLastWrittenWslAuthJson( - distro: string, - accountId: string | null, - contents: string | null - ): void { - this.lastWrittenWslAuthJsonBySelection.set( - this.getWslLaunchSelectionKey(distro, accountId), - contents - ) - } - - private readBackWslManagedAccountRefresh( - distro: string, - launchRootPath: string, - runtimeAuthPath: string, - account: CodexManagedAccount - ): void { - const launchResult = this.readBackRefreshedTokensFromPath( - this.getWslLaunchAuthPath(launchRootPath, account.id), - { - updateLastWrittenAuthJson: true, - lastWrittenAuthJson: this.getLastWrittenWslAuthJson(distro, account.id), - setLastWrittenAuthJson: (contents) => { - this.setLastWrittenWslAuthJson(distro, account.id, contents) - }, - expectedAccountId: account.id - } - ) - if (launchResult === 'unchanged') { - this.readBackRefreshedTokensFromPath(runtimeAuthPath, { - updateLastWrittenAuthJson: true, - lastWrittenAuthJson: this.getLastWrittenWslAuthJson(distro, account.id), - setLastWrittenAuthJson: (contents) => { - this.setLastWrittenWslAuthJson(distro, account.id, contents) - }, - expectedAccountId: account.id - }) + private safeMigrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void { + try { + this.migrateLegacyWslActiveHomePointer(distro, runtimeHomePath) + } catch (error) { + console.warn('[codex-runtime-home] Failed to migrate legacy WSL active Codex home:', error) } } - private preserveWslSystemDefaultRefresh( - distro: string, - launchRootPath: string, - runtimeAuthPath: string - ): void { - const systemAuthPath = this.getWslSystemCodexAuthPath({ runtime: 'wsl', wslDistro: distro }) - if (!systemAuthPath || !existsSync(systemAuthPath)) { + private migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void { + const runtimeWsl = parseWslUncPath(runtimeHomePath) + if (!runtimeWsl?.linuxPath.endsWith('/codex-runtime-home/home')) { return } - const systemAuth = readFileSync(systemAuthPath, 'utf-8') - const systemLaunchAuthPath = this.getWslLaunchAuthPath(launchRootPath, null) - const refreshedAuth = this.selectWslSystemDefaultRefreshCandidate({ - launchAuth: existsSync(systemLaunchAuthPath) - ? readFileSync(systemLaunchAuthPath, 'utf-8') - : null, - runtimeAuth: existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null, - systemAuth, - mirroredSystemDefaultAuth: this.getLastWrittenWslAuthJson(distro, null) - }) - if (!refreshedAuth) { - return - } - this.writeRuntimeAuthAtPath(systemAuthPath, refreshedAuth) - this.writeRuntimeAuthAtPath(runtimeAuthPath, refreshedAuth) - this.writeRuntimeAuthAtPath(systemLaunchAuthPath, refreshedAuth) - this.setLastWrittenWslAuthJson(distro, null, refreshedAuth) - } - - private selectWslSystemDefaultRefreshCandidate(options: { - launchAuth: string | null - runtimeAuth: string | null - systemAuth: string - mirroredSystemDefaultAuth: string | null - }): string | null { - const candidates = [options.launchAuth, options.runtimeAuth].filter((value): value is string => - Boolean(value) + const activeLinuxPath = runtimeWsl.linuxPath.replace( + /\/codex-runtime-home\/home$/, + '/codex-runtime-home/active/wsl/home' ) - const refreshedCandidates = candidates.filter((candidate) => { - if (candidate === options.systemAuth) { - return false - } - if (!this.runtimeAuthMatchesSystemDefaultIdentity(candidate, options.systemAuth)) { - return false - } - return options.mirroredSystemDefaultAuth !== null - ? options.systemAuth === options.mirroredSystemDefaultAuth - : this.runtimeAuthIsFresher(candidate, options.systemAuth) - }) - return ( - refreshedCandidates.sort((left, right) => - this.runtimeAuthIsFresher(right, left) ? 1 : -1 - )[0] ?? null + const nextLinuxPath = `${activeLinuxPath}.next-${process.pid}-${Date.now()}` + execFileSync( + 'wsl.exe', + [ + '-d', + distro, + '--', + 'bash', + '-lc', + [ + 'set -e', + 'if [ ! -e "$2" ] && [ ! -L "$2" ]; then exit 0; fi', + 'if [ -e "$2" ] && [ ! -L "$2" ]; then exit 0; fi', + 'mkdir -p "$(dirname "$2")"', + 'rm -rf -- "$3"', + 'ln -s -- "$1" "$3"', + 'mv -Tf -- "$3" "$2"' + ].join('; '), + 'sh', + runtimeWsl.linuxPath, + activeLinuxPath, + nextLinuxPath + ], + { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 } ) } @@ -1066,6 +882,18 @@ export class CodexRuntimeHomeService { } } + private safeMigrateLegacyActiveHomePointer(): void { + try { + const activeHomePath = this.getLegacyHostActiveHomePath() + if (!this.legacyActiveHomePathExists(activeHomePath)) { + return + } + this.repointLegacyActiveHomePointer(activeHomePath, this.getRuntimeHomePath()) + } catch (error) { + console.warn('[codex-runtime-home] Failed to migrate legacy active Codex home:', error) + } + } + private getRuntimeHomePath(): string { return getOrcaManagedCodexHomePath() } @@ -1074,69 +902,6 @@ export class CodexRuntimeHomeService { return join(this.getRuntimeHomePath(), 'auth.json') } - private getHostLaunchAuthPath(accountId: string | null): string { - return join(ensureOrcaCodexLaunchHome(accountId), 'auth.json') - } - - private materializeCurrentHostLaunchHome(): string { - const launchHomePath = materializeOrcaCodexLaunchHome( - normalizeCodexRuntimeSelection(this.store.getSettings()).host - ) - try { - trustCodexLaunchHomeHooks(launchHomePath) - } catch (error) { - console.warn('[codex-runtime-home] Failed to trust host launch-home hooks:', error) - } - return launchHomePath - } - - private materializeCurrentHostActiveHome(): string { - const launchHomePath = this.materializeCurrentHostLaunchHome() - // Why: terminals keep CODEX_HOME for their lifetime. Pointing that stable - // path at the selected launch home restores hot-swap for the next `codex`. - return materializeOrcaCodexActiveHome(launchHomePath) - } - - private pointWslActiveHomeAtLaunchHome(runtimeHomePath: string, launchHomePath: string): string { - const activeHomePath = this.joinWslPath(dirname(runtimeHomePath), 'active', 'wsl', 'home') - return pointActiveCodexHomeAtLaunchHome(activeHomePath, launchHomePath) - } - - private pointWslActiveHomeForLaunch( - target: CodexAccountSelectionTarget, - launchHomePath: string | null - ): string | null { - if (!launchHomePath || process.platform !== 'win32') { - return launchHomePath - } - const distro = target.wslDistro?.trim() || getDefaultWslDistro() - if (!distro) { - return launchHomePath - } - const runtimeHomePath = this.getWslRuntimeHomePath(distro) - return runtimeHomePath - ? this.pointWslActiveHomeAtLaunchHome(runtimeHomePath, launchHomePath) - : launchHomePath - } - - private getHostLaunchSelectionKey(accountId: string | null): string { - return accountId ?? 'system' - } - - private getLastWrittenHostAuthJson(accountId: string | null): string | null { - const key = this.getHostLaunchSelectionKey(accountId) - return this.lastWrittenHostAuthJsonBySelection.has(key) - ? (this.lastWrittenHostAuthJsonBySelection.get(key) ?? null) - : this.lastWrittenAuthJson - } - - private setLastWrittenHostAuthJson(accountId: string | null, contents: string | null): void { - this.lastWrittenHostAuthJsonBySelection.set(this.getHostLaunchSelectionKey(accountId), contents) - if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) { - this.lastWrittenAuthJson = contents - } - } - private getSystemDefaultSnapshotPath(): string { return join(this.getRuntimeMetadataDir(), 'system-default-auth.json') } @@ -1151,6 +916,10 @@ export class CodexRuntimeHomeService { return metadataDir } + private getLegacyHostActiveHomePath(): string { + return join(this.getRuntimeMetadataDir(), 'active', 'host', 'home') + } + private getMigrationMarkerPath(): string { return join(this.getRuntimeMetadataDir(), 'migration-v1.json') } @@ -1163,6 +932,105 @@ export class CodexRuntimeHomeService { return join(app.getPath('userData'), 'codex-accounts') } + private repointLegacyActiveHomePointer(activeHomePath: string, runtimeHomePath: string): void { + if (this.activeHomeAlreadyPointsToRuntimeHome(activeHomePath, runtimeHomePath)) { + return + } + if (!this.legacyActiveHomeLinkIsReplaceable(activeHomePath)) { + return + } + + mkdirSync(runtimeHomePath, { recursive: true }) + mkdirSync(dirname(activeHomePath), { recursive: true }) + const nextLinkPath = `${activeHomePath}.next-${process.pid}-${Date.now()}` + this.removeLegacyActiveHomeLinkIfOwned(nextLinkPath) + try { + symlinkSync( + runtimeHomePath, + nextLinkPath, + process.platform === 'win32' && lstatSync(runtimeHomePath).isDirectory() + ? 'junction' + : undefined + ) + try { + renameSync(nextLinkPath, activeHomePath) + } catch (error) { + if (!this.legacyActiveHomeLinkIsReplaceable(activeHomePath)) { + throw error + } + this.removeLegacyActiveHomeLinkIfOwned(activeHomePath) + renameSync(nextLinkPath, activeHomePath) + } + } finally { + this.removeLegacyActiveHomeLinkIfOwned(nextLinkPath) + } + } + + private activeHomeAlreadyPointsToRuntimeHome( + activeHomePath: string, + runtimeHomePath: string + ): boolean { + try { + return this.linkTargetsMatch(readlinkSync(activeHomePath), activeHomePath, runtimeHomePath) + } catch { + return false + } + } + + private linkTargetsMatch( + linkTarget: string, + linkPath: string, + expectedTargetPath: string + ): boolean { + const resolvedLinkTarget = isAbsolute(linkTarget) + ? resolve(linkTarget) + : resolve(dirname(linkPath), linkTarget) + return resolvedLinkTarget === resolve(expectedTargetPath) + } + + private legacyActiveHomeLinkIsReplaceable(activeHomePath: string): boolean { + try { + const stat = lstatSync(activeHomePath) + return stat.isSymbolicLink() || this.isWindowsReadableLink(activeHomePath) + } catch { + return true + } + } + + private legacyActiveHomePathExists(activeHomePath: string): boolean { + try { + lstatSync(activeHomePath) + return true + } catch { + return false + } + } + + private removeLegacyActiveHomeLinkIfOwned(activeHomePath: string): void { + try { + const stat = lstatSync(activeHomePath) + if (stat.isSymbolicLink()) { + unlinkSync(activeHomePath) + } else if (this.isWindowsReadableLink(activeHomePath)) { + rmdirSync(activeHomePath) + } + } catch { + // Missing or inaccessible temporary links are handled by the caller. + } + } + + private isWindowsReadableLink(targetPath: string): boolean { + if (process.platform !== 'win32') { + return false + } + try { + readlinkSync(targetPath) + return true + } catch { + return false + } + } + private migrateLegacyManagedStateIfNeeded(): void { if (existsSync(this.getMigrationMarkerPath())) { return @@ -1329,26 +1197,16 @@ export class CodexRuntimeHomeService { private syncRuntimeAuthWithSystemDefault(): void { const runtimeAuthPath = this.getRuntimeAuthPath() - const launchAuthPath = this.getHostLaunchAuthPath(null) const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') - if (!existsSync(runtimeAuthPath) && !existsSync(launchAuthPath)) { + if (!existsSync(runtimeAuthPath)) { return } try { - const launchAuth = existsSync(launchAuthPath) ? readFileSync(launchAuthPath, 'utf-8') : null - const sharedAuth = existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null + const runtimeAuth = readFileSync(runtimeAuthPath, 'utf-8') if (!existsSync(systemDefaultAuthPath)) { const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) const mirroredSystemDefaultAuth = this.lastWrittenAuthJson ?? snapshot?.authJson ?? null - const runtimeAuth = this.selectSystemDefaultRuntimeAuthCandidate({ - launchAuth, - sharedAuth, - mirroredSystemDefaultAuth - }) - if (runtimeAuth === null) { - return - } if (mirroredSystemDefaultAuth !== null && runtimeAuth === mirroredSystemDefaultAuth) { this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) return @@ -1362,17 +1220,9 @@ export class CodexRuntimeHomeService { return } const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') - const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) - const mirroredSystemDefaultAuth = this.lastWrittenAuthJson ?? snapshot?.authJson ?? null - const runtimeAuth = this.selectSystemDefaultRuntimeAuthCandidate({ - launchAuth, - sharedAuth, - mirroredSystemDefaultAuth: mirroredSystemDefaultAuth ?? systemDefaultAuth - }) - if (runtimeAuth === null) { - return - } if (runtimeAuth !== systemDefaultAuth) { + const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) + const mirroredSystemDefaultAuth = this.lastWrittenAuthJson ?? snapshot?.authJson ?? null if ( mirroredSystemDefaultAuth !== null && systemDefaultAuth === mirroredSystemDefaultAuth && @@ -1383,9 +1233,7 @@ export class CodexRuntimeHomeService { // sync does not overwrite fresh runtime credentials with stale ones. this.writeSystemDefaultAuth(runtimeAuth) this.captureSystemDefaultSnapshot({ force: true }) - this.setLastWrittenHostAuthJson(null, runtimeAuth) - this.writeRuntimeAuthAtPath(runtimeAuthPath, runtimeAuth) - this.writeRuntimeAuthAtPath(launchAuthPath, runtimeAuth) + this.lastWrittenAuthJson = runtimeAuth return } // Why: the unmanaged path used to read ~/.codex directly. Mirror later @@ -1393,68 +1241,12 @@ export class CodexRuntimeHomeService { // Codex sessions keep matching the user's current system-default state. this.captureSystemDefaultSnapshot({ force: true }) this.writeRuntimeAuth(systemDefaultAuth) - } else if (sharedAuth !== null && sharedAuth !== runtimeAuth) { - this.writeRuntimeAuthAtPath(runtimeAuthPath, runtimeAuth) } } catch (error) { console.warn('[codex-runtime-home] Failed to sync system-default auth:', error) } } - private selectSystemDefaultRuntimeAuthCandidate(options: { - launchAuth: string | null - sharedAuth: string | null - mirroredSystemDefaultAuth: string | null - }): string | null { - const launchMatches = this.systemDefaultCandidateMatchesMirror( - options.launchAuth, - options.mirroredSystemDefaultAuth - ) - const sharedMatches = this.systemDefaultCandidateMatchesMirror( - options.sharedAuth, - options.mirroredSystemDefaultAuth - ) - const launchChanged = launchMatches && options.launchAuth !== options.mirroredSystemDefaultAuth - const sharedChanged = sharedMatches && options.sharedAuth !== options.mirroredSystemDefaultAuth - - if (launchChanged && !sharedChanged) { - return options.launchAuth - } - if (sharedChanged && !launchChanged) { - return options.sharedAuth - } - if (launchChanged && sharedChanged) { - if ( - options.launchAuth !== null && - options.sharedAuth !== null && - this.runtimeAuthIsFresher(options.sharedAuth, options.launchAuth) - ) { - return options.sharedAuth - } - return options.launchAuth - } - if (launchMatches) { - return options.launchAuth - } - if (sharedMatches) { - return options.sharedAuth - } - return options.launchAuth ?? options.sharedAuth - } - - private systemDefaultCandidateMatchesMirror( - authJson: string | null, - mirroredSystemDefaultAuth: string | null - ): boolean { - if (authJson === null) { - return false - } - return ( - mirroredSystemDefaultAuth === null || - this.runtimeAuthMatchesSystemDefaultIdentity(authJson, mirroredSystemDefaultAuth) - ) - } - private restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void { const snapshotPath = this.getSystemDefaultSnapshotPath() const runtimeAuthPath = this.getRuntimeAuthPath() @@ -1471,7 +1263,7 @@ export class CodexRuntimeHomeService { // a local logout signal for Orca-launched Codex sessions, not a reason to // rewrite the user's real ~/.codex snapshot back into place. this.persistRuntimeLogoutMarker() - this.clearHostRuntimeAuthBaseline() + this.lastWrittenAuthJson = null return } @@ -1479,10 +1271,10 @@ export class CodexRuntimeHomeService { // Why: while a managed account is selected, the runtime auth file exists // with managed credentials. If ~/.codex/auth.json vanished meanwhile, // switching back must preserve that external system-default logout. - this.removeHostRuntimeAuth(runtimeAuthPath) + rmSync(runtimeAuthPath, { force: true }) this.captureSystemDefaultSnapshot({ force: true }) this.persistRuntimeLogoutMarker() - this.clearHostRuntimeAuthBaseline() + this.lastWrittenAuthJson = null return } @@ -1497,21 +1289,21 @@ export class CodexRuntimeHomeService { this.captureSystemDefaultSnapshot({ force: true }) const refreshedSnapshot = this.readSystemDefaultSnapshot(snapshotPath) if (!refreshedSnapshot) { - this.removeHostRuntimeAuth(runtimeAuthPath) - this.clearHostRuntimeAuthBaseline() + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenAuthJson = null return } if (refreshedSnapshot.authJson === null) { - this.removeHostRuntimeAuth(runtimeAuthPath) - this.clearHostRuntimeAuthBaseline() + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenAuthJson = null return } this.writeRuntimeAuth(refreshedSnapshot.authJson) return } if (snapshot.authJson === null) { - this.removeHostRuntimeAuth(runtimeAuthPath) - this.clearHostRuntimeAuthBaseline() + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenAuthJson = null return } this.writeRuntimeAuth(snapshot.authJson) @@ -1528,10 +1320,10 @@ export class CodexRuntimeHomeService { // Why: when the real ~/.codex auth disappears, Orca should treat that as an // external logout for unmanaged sessions, even if runtime auth had already // refreshed inside Orca's CODEX_HOME. - this.removeHostRuntimeAuth(runtimeAuthPath) + rmSync(runtimeAuthPath, { force: true }) this.captureSystemDefaultSnapshot({ force: true }) this.persistRuntimeLogoutMarker() - this.clearHostRuntimeAuthBaseline() + this.lastWrittenAuthJson = null } private readSystemDefaultAuth(): string | null { @@ -1543,18 +1335,13 @@ export class CodexRuntimeHomeService { // Why: auth.json contains sensitive credentials. Restrict to owner-only // so other users on a shared Linux/macOS machine cannot read it. this.clearRuntimeLogoutMarker() - const accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host - const runtimeAuthPath = this.getRuntimeAuthPath() - const launchAuthPath = this.getHostLaunchAuthPath(accountId) - if (this.fileContentsEqual(runtimeAuthPath, contents)) { - this.ensureOwnerOnlyMode(runtimeAuthPath) - this.setLastWrittenHostAuthJson(accountId, contents) - this.writeRuntimeAuthAtPath(launchAuthPath, contents) + if (this.fileContentsEqual(this.getRuntimeAuthPath(), contents)) { + this.ensureOwnerOnlyMode(this.getRuntimeAuthPath()) + this.lastWrittenAuthJson = contents return } - writeFileAtomically(runtimeAuthPath, contents, { mode: 0o600 }) - this.setLastWrittenHostAuthJson(accountId, contents) - this.writeRuntimeAuthAtPath(launchAuthPath, contents) + writeFileAtomically(this.getRuntimeAuthPath(), contents, { mode: 0o600 }) + this.lastWrittenAuthJson = contents } private writeRuntimeAuthAtPath(authPath: string, contents: string): void { @@ -1566,21 +1353,6 @@ export class CodexRuntimeHomeService { writeFileAtomically(authPath, contents, { mode: 0o600 }) } - private removeHostRuntimeAuth(runtimeAuthPath: string): void { - rmSync(runtimeAuthPath, { force: true }) - rmSync( - this.getHostLaunchAuthPath(normalizeCodexRuntimeSelection(this.store.getSettings()).host), - { force: true } - ) - } - - private clearHostRuntimeAuthBaseline(): void { - this.setLastWrittenHostAuthJson( - normalizeCodexRuntimeSelection(this.store.getSettings()).host, - null - ) - } - private fileContentsEqual(targetPath: string, contents: string): boolean { try { return existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === contents diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index d3fa9d571..248871f86 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -16,7 +16,11 @@ import { join } from 'node:path' import { PassThrough } from 'node:stream' import type { GlobalSettings } from '../../shared/types' -const testState = { userDataDir: '', fakeHomeDir: '' } +const testState = { + userDataDir: '', + fakeHomeDir: '', + previousUserDataPath: undefined as string | undefined +} vi.mock('electron', () => ({ app: { @@ -166,7 +170,6 @@ function createRateLimits() { function createRuntimeHome() { return { syncForCurrentSelection: vi.fn(), - refreshCurrentLaunchHome: vi.fn(() => null), clearLastWrittenAuthJson: vi.fn() } } @@ -184,18 +187,40 @@ function createManagedHome(rootDir: string, accountId: string, config = '', auth return managedHomePath } +function createCodexAuthJson(email: string, accountId: string, refreshToken: string): string { + const payload = Buffer.from(JSON.stringify({ email })).toString('base64url') + return `${JSON.stringify( + { + tokens: { + id_token: `header.${payload}.signature`, + account_id: accountId, + refresh_token: refreshToken + } + }, + null, + 2 + )}\n` +} + describe('CodexAccountService config sync', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-accounts-')) testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-home-')) + testState.previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = testState.userDataDir mkdirSync(join(testState.fakeHomeDir, '.codex'), { recursive: true }) }) afterEach(() => { rmSync(testState.userDataDir, { recursive: true, force: true }) rmSync(testState.fakeHomeDir, { recursive: true, force: true }) + if (testState.previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = testState.previousUserDataPath + } }) it('syncs the canonical ~/.codex/config.toml into managed homes on startup', async () => { @@ -827,6 +852,70 @@ describe('CodexAccountService config sync', () => { expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalled() }) + it('selectAccount immediately rewrites the shared runtime auth for existing terminals', async () => { + const firstAuth = createCodexAuthJson('one@example.com', 'acct-one', 'one') + const secondAuth = createCodexAuthJson('two@example.com', 'acct-two', 'two') + const firstManagedHomePath = createManagedHome( + testState.userDataDir, + 'account-1', + '', + firstAuth + ) + const secondManagedHomePath = createManagedHome( + testState.userDataDir, + 'account-2', + '', + secondAuth + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'one@example.com', + managedHomePath: firstManagedHomePath, + providerAccountId: 'acct-one', + workspaceLabel: null, + workspaceAccountId: 'acct-one', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'two@example.com', + managedHomePath: secondManagedHomePath, + providerAccountId: 'acct-two', + workspaceLabel: null, + workspaceAccountId: 'acct-two', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const rateLimits = createRateLimits() + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const runtimeHome = new CodexRuntimeHomeService(store as never) + const runtimeAuthPath = join(testState.userDataDir, 'codex-runtime-home', 'home', 'auth.json') + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(firstAuth) + + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + runtimeHome as never + ) + + await service.selectAccount('account-2') + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(secondAuth) + expect(existsSync(join(testState.userDataDir, 'codex-runtime-home', 'launch'))).toBe(false) + expect(existsSync(join(testState.userDataDir, 'codex-runtime-home', 'active'))).toBe(false) + }) + it('keeps Windows and WSL active Codex account selections separate', async () => { const hostManagedHomePath = createManagedHome( testState.userDataDir, diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 3e885f6d0..0aef471c7 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -156,7 +156,6 @@ export class CodexAccountService { this.safeSyncCanonicalConfigToManagedHomes() this.runtimeHome.clearLastWrittenAuthJson(account.id) this.runtimeHome.syncForCurrentSelection() - this.runtimeHome.refreshCurrentLaunchHome(targetSelection) // Why: the new account becomes active, so the previous active account is // now inactive and its last-known usage should be cached for the switcher. @@ -201,7 +200,6 @@ export class CodexAccountService { this.safeSyncCanonicalConfigToManagedHomes() this.runtimeHome.clearLastWrittenAuthJson(accountId) this.runtimeHome.syncForCurrentSelection(getCodexSelectionTargetForAccount(account)) - this.runtimeHome.refreshCurrentLaunchHome(getCodexSelectionTargetForAccount(account)) // Why: re-auth can change which actual Codex identity the managed home // points at. Force a fresh read immediately so the status bar cannot keep @@ -230,9 +228,7 @@ export class CodexAccountService { activeCodexManagedAccountIdsByRuntime: nextSelection }) this.runtimeHome.syncForCurrentSelection() - this.runtimeHome.refreshCurrentLaunchHome(getCodexSelectionTargetForAccount(account)) - this.runtimeHome.removeLaunchHomeForAccount?.(account) this.safeRemoveManagedHome(account.managedHomePath) // Why: a removed account can no longer appear in the switcher dropdown, // so purge its cached usage to avoid stale entries. @@ -279,7 +275,6 @@ export class CodexAccountService { }) this.safeSyncCanonicalConfigToManagedHomes() this.runtimeHome.syncForCurrentSelection(effectiveTarget) - this.runtimeHome.refreshCurrentLaunchHome(effectiveTarget) await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId, effectiveTarget) return this.getSnapshot() diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 50280fa05..17865439a 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -1,16 +1,5 @@ -/* eslint-disable max-lines -- Why: these cases exercise one stateful Codex -config sync contract across first-run, upgrade, corrupt-state, and trust -preservation paths. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - existsSync, - mkdtempSync, - mkdirSync, - readFileSync, - rmSync, - utimesSync, - writeFileSync -} from 'node:fs' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import type * as NodeOs from 'node:os' import { join } from 'node:path' @@ -52,15 +41,6 @@ function getRuntimeConfigPath(): string { return join(userDataDir, 'codex-runtime-home', 'home', 'config.toml') } -function getConfigSyncStatePath(): string { - return join(userDataDir, 'codex-runtime-home', 'config-sync-state.json') -} - -function establishSystemConfigBaseline(config: string): void { - writeFileSync(getSystemConfigPath(), config, 'utf-8') - syncSystemConfigIntoManagedCodexHome() -} - beforeEach(() => { fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-config-home-')) userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-config-user-data-')) @@ -113,45 +93,6 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]') }) - it('treats whitespace-formatted hook trust headers as runtime-owned', () => { - writeFileSync( - getSystemConfigPath(), - [ - 'model = "system-model"', - '', - '["hooks" . "state" . "system-hooks:stop:0:0"]', - 'enabled = true', - 'trusted_hash = "sha256:system"', - '' - ].join('\n'), - 'utf-8' - ) - - syncSystemConfigIntoManagedCodexHome() - writeFileSync( - getRuntimeConfigPath(), - [ - 'model = "runtime-model"', - '', - '["hooks" . "state" . "runtime-hooks:stop:0:0"]', - 'enabled = false', - 'trusted_hash = "sha256:runtime"', - '' - ].join('\n'), - 'utf-8' - ) - writeFileSync(getSystemConfigPath(), 'model = "next-system-model"\n', 'utf-8') - - syncSystemConfigIntoManagedCodexHome() - - const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') - expect(runtimeConfig).toContain('model = "next-system-model"') - expect(runtimeConfig).toContain('["hooks" . "state" . "runtime-hooks:stop:0:0"]') - expect(runtimeConfig).toContain('trusted_hash = "sha256:runtime"') - expect(runtimeConfig).not.toContain('["hooks" . "state" . "system-hooks:stop:0:0"]') - expect(runtimeConfig).not.toContain('trusted_hash = "sha256:system"') - }) - it('normalizes deprecated codex_hooks feature flag only in runtime config', () => { writeFileSync( getSystemConfigPath(), @@ -182,7 +123,7 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { }) it('mirrors system config updates while preserving runtime-owned trust sections', () => { - establishSystemConfigBaseline('model = "initial-system-model"\n') + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) writeFileSync( getRuntimeConfigPath(), [ @@ -234,763 +175,8 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { expect(runtimeConfig.match(/\[projects\."\/repo"\]/g)?.length).toBe(1) }) - it('keeps runtime Codex preference changes when the system config has not changed', () => { - mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) - writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8') - - syncSystemConfigIntoManagedCodexHome() - writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8') - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n') - }) - - it('keeps runtime Codex preference changes when the system config is missing', () => { - mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) - writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8') - - syncSystemConfigIntoManagedCodexHome() - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n') - }) - - it('keeps runtime preferences on first baseline while honoring system project trust', () => { - mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) - writeFileSync( - getSystemConfigPath(), - [ - 'model = "system-model"', - '', - '[projects."/repo"] # explicit revocation', - 'trust_level = "untrusted"', - '', - '[projects."/system-only"]', - 'trust_level = "trusted"', - '', - '[hooks.state."system-hooks:stop:0:0"]', - 'enabled = true', - 'trusted_hash = "sha256:system"', - '' - ].join('\n'), - 'utf-8' - ) - writeFileSync( - getRuntimeConfigPath(), - [ - 'model = "runtime-model"', - '', - '[projects."/repo"]', - 'trust_level = "trusted"', - 'metadata = "runtime-owned"', - '', - '[projects."/runtime-only"]', - 'trust_level = "trusted"', - '', - '[hooks.state."runtime-hooks:stop:0:0"]', - 'enabled = true', - 'trusted_hash = "sha256:runtime"', - '' - ].join('\n'), - 'utf-8' - ) - utimesSync( - getSystemConfigPath(), - new Date('2024-01-01T00:00:00Z'), - new Date('2024-01-01T00:00:00Z') - ) - utimesSync( - getRuntimeConfigPath(), - new Date('2024-01-01T00:01:00Z'), - new Date('2024-01-01T00:01:00Z') - ) - - syncSystemConfigIntoManagedCodexHome() - - const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') - expect(runtimeConfig).toContain('model = "runtime-model"') - expect(runtimeConfig).not.toContain('model = "system-model"') - expect(runtimeConfig).toContain('[projects."/repo"]') - expect(runtimeConfig).toContain('trust_level = "untrusted"') - expect(runtimeConfig).toContain('metadata = "runtime-owned"') - expect(runtimeConfig).toContain('[projects."/runtime-only"]') - expect(runtimeConfig).toContain('[projects."/system-only"]') - expect(runtimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]') - expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]') - expect(runtimeConfig.match(/\[projects\."\/repo"\]/g)?.length).toBe(1) - - writeFileSync(getSystemConfigPath(), 'model = "next-system-model"\n', 'utf-8') - syncSystemConfigIntoManagedCodexHome() - - const updatedRuntimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') - expect(updatedRuntimeConfig).toContain('model = "next-system-model"') - expect(updatedRuntimeConfig).not.toContain('model = "runtime-model"') - expect(updatedRuntimeConfig).toContain('[projects."/runtime-only"]') - expect(updatedRuntimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]') - }) - - it('baselines skipped no-baseline system settings until their contents change', () => { - mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) - writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8') - writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8') - utimesSync( - getSystemConfigPath(), - new Date('2024-01-01T00:00:00Z'), - new Date('2024-01-01T00:00:00Z') - ) - utimesSync( - getRuntimeConfigPath(), - new Date('2024-01-01T00:01:00Z'), - new Date('2024-01-01T00:01:00Z') - ) - - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n') - expect(readFileSync(getConfigSyncStatePath(), 'utf-8')).toMatch( - /"lastMirrorableSystemConfigDigest": "sha256:[a-f0-9]{64}"/ - ) - - utimesSync( - getSystemConfigPath(), - new Date('2024-01-01T00:02:00Z'), - new Date('2024-01-01T00:02:00Z') - ) - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n') - - writeFileSync(getSystemConfigPath(), 'model = "next-system-model"\n', 'utf-8') - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "next-system-model"\n') - }) - - it('keeps runtime preferences when only system project trust changes', () => { - establishSystemConfigBaseline('model = "system-model"\n') - writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8') - writeFileSync( - getSystemConfigPath(), - [ - 'model = "system-model"', - '', - '[projects."/new-system-project"]', - 'trust_level = "trusted"', - '', - '[hooks.state."system-hooks:stop:0:0"]', - 'enabled = true', - 'trusted_hash = "sha256:system"', - '' - ].join('\n'), - 'utf-8' - ) - - syncSystemConfigIntoManagedCodexHome() - - const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') - expect(runtimeConfig).toContain('model = "runtime-model"') - expect(runtimeConfig).toContain('[projects."/new-system-project"]') - expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]') - }) - - it('keeps runtime preferences when an unrelated ordinary system section changes', () => { - establishSystemConfigBaseline('model = "system-model"\n') - writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8') - writeFileSync( - getSystemConfigPath(), - [ - 'model = "system-model"', - '', - '[mcp_servers.files]', - 'command = "node"', - 'args = ["server.js"]', - '' - ].join('\n'), - 'utf-8' - ) - - syncSystemConfigIntoManagedCodexHome() - - const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') - expect(runtimeConfig).toContain('model = "runtime-model"') - expect(runtimeConfig).toContain('[mcp_servers.files]') - expect(runtimeConfig).toContain('command = "node"') - expect(runtimeConfig).not.toContain('model = "system-model"') - }) - - it('matches equivalent ordinary table headers before merging system sections', () => { - establishSystemConfigBaseline( - ['model = "system-model"', '', '[mcp_servers.files]', 'command = "node"', ''].join('\n') - ) - writeFileSync( - getRuntimeConfigPath(), - [ - 'model = "runtime-model"', - '', - '[mcp_servers.files]', - 'command = "node"', - 'args = ["runtime.js"]', - '' - ].join('\n'), - 'utf-8' - ) - writeFileSync( - getSystemConfigPath(), - ['model = "system-model"', '', '[mcp_servers . files]', 'command = "node"', ''].join('\n'), - 'utf-8' - ) - - syncSystemConfigIntoManagedCodexHome() - - const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') - expect(runtimeConfig).toContain('model = "runtime-model"') - expect(runtimeConfig).toContain('[mcp_servers.files]') - expect(runtimeConfig).toContain('args = ["runtime.js"]') - expect(runtimeConfig).not.toContain('[mcp_servers . files]') - expect(runtimeConfig.match(/mcp_servers/g)?.length).toBe(1) - }) - - it('keeps changed top-level settings before TOML table sections', () => { - establishSystemConfigBaseline( - ['model = "system-model"', '', '[mcp_servers.files]', 'command = "node"', ''].join('\n') - ) - writeFileSync( - getRuntimeConfigPath(), - [ - 'model = "runtime-model"', - '', - 'model_reasoning_effort = "low"', - '', - '[mcp_servers.files]', - 'command = "node"', - '' - ].join('\n'), - 'utf-8' - ) - writeFileSync( - getSystemConfigPath(), - ['model = "next-system-model"', '', '[mcp_servers.files]', 'command = "node"', ''].join('\n'), - 'utf-8' - ) - - syncSystemConfigIntoManagedCodexHome() - - const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') - expect(runtimeConfig).toContain('model = "next-system-model"') - expect(runtimeConfig.indexOf('model = "next-system-model"')).toBeLessThan( - runtimeConfig.indexOf('[mcp_servers.files]') - ) - }) - - it('keeps runtime preferences when the system config is deleted after a baseline', () => { - establishSystemConfigBaseline('model = "system-model"\n') - writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8') - rmSync(getSystemConfigPath(), { force: true }) - - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n') - }) - - it('keeps runtime-edited settings when a deleted system config reappears unchanged', () => { - establishSystemConfigBaseline('model = "system-model"\n') - writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8') - rmSync(getSystemConfigPath(), { force: true }) - - syncSystemConfigIntoManagedCodexHome() - writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8') - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n') - }) - - it('removes unchanged mirrored settings when the system config is deleted', () => { - establishSystemConfigBaseline( - ['model = "system-model"', 'model_reasoning_effort = "high"', ''].join('\n') - ) - rmSync(getSystemConfigPath(), { force: true }) - - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('') - }) - - it('keeps a migrated legacy baseline when system config is temporarily missing', () => { - const systemConfig = 'model = "system-model"\n' - mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) - writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8') - writeFileSync( - getConfigSyncStatePath(), - `${JSON.stringify({ lastSystemConfig: systemConfig }, null, 2)}\n`, - 'utf-8' - ) - - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n') - expect(readFileSync(getConfigSyncStatePath(), 'utf-8')).toContain('lastSystemConfigUnitDigests') - - writeFileSync(getSystemConfigPath(), systemConfig, 'utf-8') - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n') - - writeFileSync(getSystemConfigPath(), 'model = "next-system-model"\n', 'utf-8') - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "next-system-model"\n') - }) - - it('defers digest-only state migration while system config is temporarily missing', () => { - const systemConfig = 'model = "system-model"\n' - establishSystemConfigBaseline(systemConfig) - const state = JSON.parse(readFileSync(getConfigSyncStatePath(), 'utf-8')) as { - lastMirrorableSystemConfigDigest: string - } - writeFileSync( - getConfigSyncStatePath(), - `${JSON.stringify( - { lastMirrorableSystemConfigDigest: state.lastMirrorableSystemConfigDigest }, - null, - 2 - )}\n`, - 'utf-8' - ) - writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8') - rmSync(getSystemConfigPath(), { force: true }) - - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n') - - writeFileSync(getSystemConfigPath(), systemConfig, 'utf-8') - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n') - expect(readFileSync(getConfigSyncStatePath(), 'utf-8')).toContain('lastSystemConfigUnitDigests') - }) - - it('applies system changes when digest-only state proves runtime still matches baseline', () => { - const systemConfig = 'model = "system-model"\n' - establishSystemConfigBaseline(systemConfig) - const state = JSON.parse(readFileSync(getConfigSyncStatePath(), 'utf-8')) as { - lastMirrorableSystemConfigDigest: string - } - writeFileSync( - getConfigSyncStatePath(), - `${JSON.stringify( - { lastMirrorableSystemConfigDigest: state.lastMirrorableSystemConfigDigest }, - null, - 2 - )}\n`, - 'utf-8' - ) - writeFileSync(getSystemConfigPath(), 'model = "next-system-model"\n', 'utf-8') - - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "next-system-model"\n') - expect(readFileSync(getConfigSyncStatePath(), 'utf-8')).toContain('lastSystemConfigUnitDigests') - }) - - it('baselines digest-only recovery after preserving ambiguous runtime edits', () => { - const systemConfig = 'model = "system-model"\n' - establishSystemConfigBaseline(systemConfig) - const state = JSON.parse(readFileSync(getConfigSyncStatePath(), 'utf-8')) as { - lastMirrorableSystemConfigDigest: string - } - writeFileSync( - getConfigSyncStatePath(), - `${JSON.stringify( - { lastMirrorableSystemConfigDigest: state.lastMirrorableSystemConfigDigest }, - null, - 2 - )}\n`, - 'utf-8' - ) - writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8') - writeFileSync(getSystemConfigPath(), 'model = "current-system-model"\n', 'utf-8') - - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n') - - writeFileSync(getSystemConfigPath(), 'model = "next-system-model"\n', 'utf-8') - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "next-system-model"\n') - }) - - it('rewrites hybrid legacy sync state without keeping sensitive system config contents', () => { - const systemConfig = 'model = "system-model"\napi_key = "sk-sensitive-value"\n' - mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) - writeFileSync(getRuntimeConfigPath(), systemConfig, 'utf-8') - writeFileSync(getSystemConfigPath(), systemConfig, 'utf-8') - syncSystemConfigIntoManagedCodexHome() - const stateWithDigest = JSON.parse(readFileSync(getConfigSyncStatePath(), 'utf-8')) as { - lastMirrorableSystemConfigDigest: string - } - writeFileSync( - getConfigSyncStatePath(), - `${JSON.stringify({ ...stateWithDigest, lastSystemConfig: systemConfig }, null, 2)}\n`, - 'utf-8' - ) - - syncSystemConfigIntoManagedCodexHome() - - const state = readFileSync(getConfigSyncStatePath(), 'utf-8') - expect(state).toContain('lastMirrorableSystemConfigDigest') - expect(state).toMatch(/sha256:[a-f0-9]{64}/) - expect(state).not.toContain('sk-sensitive-value') - expect(state).not.toContain('api_key') - expect(Object.hasOwn(JSON.parse(state) as Record, 'lastSystemConfig')).toBe( - false - ) - }) - - it('rewrites hybrid sync state with a non-string legacy key', () => { - const systemConfig = 'model = "system-model"\n' - mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) - writeFileSync(getRuntimeConfigPath(), systemConfig, 'utf-8') - writeFileSync(getSystemConfigPath(), systemConfig, 'utf-8') - syncSystemConfigIntoManagedCodexHome() - const stateWithDigest = JSON.parse(readFileSync(getConfigSyncStatePath(), 'utf-8')) as { - lastMirrorableSystemConfigDigest: string - } - writeFileSync( - getConfigSyncStatePath(), - `${JSON.stringify({ ...stateWithDigest, lastSystemConfig: { token: 'sk-sensitive-value' } }, null, 2)}\n`, - 'utf-8' - ) - - syncSystemConfigIntoManagedCodexHome() - - const parsed = JSON.parse(readFileSync(getConfigSyncStatePath(), 'utf-8')) as Record< - string, - unknown - > - expect(parsed.lastMirrorableSystemConfigDigest).toMatch(/^sha256:[a-f0-9]{64}$/) - expect(Object.hasOwn(parsed, 'lastSystemConfig')).toBe(false) - }) - - it('keeps runtime preferences when the first sync baseline is missing', () => { - mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) - writeFileSync( - getRuntimeConfigPath(), - [ - 'model = "stale-runtime-model"', - '', - '[hooks.state."runtime-hooks:stop:0:0"]', - 'enabled = true', - 'trusted_hash = "sha256:runtime"', - '', - '[projects."/system-project"]', - 'trust_level = "trusted"', - '' - ].join('\n'), - 'utf-8' - ) - writeFileSync( - getSystemConfigPath(), - [ - 'model = "new-system-model"', - '', - '[hooks.state."system-hooks:stop:0:0"]', - 'enabled = true', - 'trusted_hash = "sha256:system"', - '', - '[projects."/system-project"]', - 'trust_level = "untrusted"', - '' - ].join('\n'), - 'utf-8' - ) - utimesSync( - getRuntimeConfigPath(), - new Date('2024-01-01T00:00:00Z'), - new Date('2024-01-01T00:00:00Z') - ) - utimesSync( - getSystemConfigPath(), - new Date('2024-01-01T00:01:00Z'), - new Date('2024-01-01T00:01:00Z') - ) - - syncSystemConfigIntoManagedCodexHome() - - const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') - expect(runtimeConfig).toContain('model = "stale-runtime-model"') - expect(runtimeConfig).not.toContain('model = "new-system-model"') - expect(runtimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]') - expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]') - expect(runtimeConfig).toContain('[projects."/system-project"]') - expect(runtimeConfig).toContain('trust_level = "untrusted"') - }) - - it('updates project trust when the system project trust changes from untrusted to trusted', () => { - establishSystemConfigBaseline( - ['model = "system-model"', '', '[projects."/repo"]', 'trust_level = "untrusted"', ''].join( - '\n' - ) - ) - writeFileSync(getSystemConfigPath(), '[projects."/repo"]\ntrust_level = "trusted"\n', 'utf-8') - - syncSystemConfigIntoManagedCodexHome() - - const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') - expect(runtimeConfig).toContain('[projects."/repo"]') - expect(runtimeConfig).toContain('trust_level = "trusted"') - expect(runtimeConfig).not.toContain('trust_level = "untrusted"') - }) - - it('updates project trust without removing runtime-owned project settings', () => { - establishSystemConfigBaseline(['[projects."/repo"]', 'trust_level = "trusted"', ''].join('\n')) - writeFileSync( - getRuntimeConfigPath(), - ['[projects."/repo"]', 'trust_level = "trusted"', 'metadata = "runtime-owned"', ''].join( - '\n' - ), - 'utf-8' - ) - writeFileSync(getSystemConfigPath(), '[projects."/repo"]\ntrust_level = "untrusted"\n') - - syncSystemConfigIntoManagedCodexHome() - - const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') - expect(runtimeConfig).toContain('[projects."/repo"]') - expect(runtimeConfig).toContain('trust_level = "untrusted"') - expect(runtimeConfig).toContain('metadata = "runtime-owned"') - expect(runtimeConfig).not.toContain('trust_level = "trusted"') - }) - - it('applies system trusted project state during missing-baseline recovery', () => { - mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) - writeFileSync(getRuntimeConfigPath(), '[projects."/repo"]\ntrust_level = "untrusted"\n') - writeFileSync(getSystemConfigPath(), '[projects."/repo"]\ntrust_level = "trusted"\n') - - syncSystemConfigIntoManagedCodexHome() - - const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') - expect(runtimeConfig).toContain('[projects."/repo"]') - expect(runtimeConfig).toContain('trust_level = "trusted"') - expect(runtimeConfig).not.toContain('trust_level = "untrusted"') - }) - - it('matches equivalent quoted project headers before applying system untrust', () => { - mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) - writeFileSync( - getRuntimeConfigPath(), - ["[projects . 'C:\\Repo']", 'trust_level = "trusted"', ''].join('\n'), - 'utf-8' - ) - writeFileSync( - getSystemConfigPath(), - ['[projects."c:/repo"]', 'trust_level = "untrusted"', ''].join('\n'), - 'utf-8' - ) - - syncSystemConfigIntoManagedCodexHome() - - const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') - expect(runtimeConfig).toContain("[projects . 'C:\\Repo']") - expect(runtimeConfig).toContain('trust_level = "untrusted"') - expect(runtimeConfig).not.toContain('[projects."c:/repo"]') - expect(runtimeConfig.match(/trust_level/g)?.length).toBe(1) - }) - - it('tracks duplicate array-table sections by occurrence', () => { - establishSystemConfigBaseline( - [ - '[[hooks.PermissionRequest]]', - 'command = "first"', - '', - '[[hooks.PermissionRequest]]', - 'command = "second"', - '' - ].join('\n') - ) - writeFileSync( - getSystemConfigPath(), - [ - '[[hooks.PermissionRequest]]', - 'command = "first"', - '', - '[[hooks.PermissionRequest]]', - 'command = "updated-second"', - '' - ].join('\n'), - 'utf-8' - ) - - syncSystemConfigIntoManagedCodexHome() - - const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') - expect(runtimeConfig).toContain('command = "first"') - expect(runtimeConfig).toContain('command = "updated-second"') - expect(runtimeConfig).not.toContain('command = "second"') - expect(runtimeConfig.match(/\[\[hooks\.PermissionRequest\]\]/g)?.length).toBe(2) - }) - - it('preserves duplicate array-table order when an earlier occurrence changes', () => { - establishSystemConfigBaseline( - [ - '[[hooks.PermissionRequest]]', - 'command = "first"', - '', - '[[hooks.PermissionRequest]]', - 'command = "second"', - '' - ].join('\n') - ) - writeFileSync( - getSystemConfigPath(), - [ - '[[hooks.PermissionRequest]]', - 'command = "updated-first"', - '', - '[[hooks.PermissionRequest]]', - 'command = "second"', - '' - ].join('\n'), - 'utf-8' - ) - - syncSystemConfigIntoManagedCodexHome() - - const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') - expect(runtimeConfig).toContain('command = "updated-first"') - expect(runtimeConfig).toContain('command = "second"') - expect(runtimeConfig).not.toContain('command = "first"') - expect(runtimeConfig.indexOf('command = "updated-first"')).toBeLessThan( - runtimeConfig.indexOf('command = "second"') - ) - expect(runtimeConfig.match(/\[\[hooks\.PermissionRequest\]\]/g)?.length).toBe(2) - }) - - it('recovers a corrupt sync state with newer system mtime without clobbering runtime preferences', () => { - writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8') - syncSystemConfigIntoManagedCodexHome() - writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8') - writeFileSync(getConfigSyncStatePath(), '{not-json', 'utf-8') - utimesSync( - getRuntimeConfigPath(), - new Date('2024-01-01T00:00:00Z'), - new Date('2024-01-01T00:00:00Z') - ) - utimesSync( - getSystemConfigPath(), - new Date('2024-01-01T00:01:00Z'), - new Date('2024-01-01T00:01:00Z') - ) - - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n') - expect(readFileSync(getConfigSyncStatePath(), 'utf-8')).toMatch( - /"lastMirrorableSystemConfigDigest": "sha256:[a-f0-9]{64}"/ - ) - - writeFileSync(getSystemConfigPath(), 'model = "next-system-model"\n', 'utf-8') - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "next-system-model"\n') - }) - - it('recovers an invalid sync-state digest with newer system mtime without clobbering runtime preferences', () => { - writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8') - syncSystemConfigIntoManagedCodexHome() - writeFileSync(getRuntimeConfigPath(), 'model = "runtime-model"\n', 'utf-8') - writeFileSync( - getConfigSyncStatePath(), - `${JSON.stringify({ lastMirrorableSystemConfigDigest: 'not-a-digest' }, null, 2)}\n`, - 'utf-8' - ) - utimesSync( - getRuntimeConfigPath(), - new Date('2024-01-01T00:00:00Z'), - new Date('2024-01-01T00:00:00Z') - ) - utimesSync( - getSystemConfigPath(), - new Date('2024-01-01T00:01:00Z'), - new Date('2024-01-01T00:01:00Z') - ) - - syncSystemConfigIntoManagedCodexHome() - - expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe('model = "runtime-model"\n') - expect(readFileSync(getConfigSyncStatePath(), 'utf-8')).toMatch( - /"lastMirrorableSystemConfigDigest": "sha256:[a-f0-9]{64}"/ - ) - }) - - it('does not duplicate sensitive system config contents in sync state', () => { - writeFileSync( - getSystemConfigPath(), - 'model = "system-model"\napi_key = "sk-sensitive-value"\n', - 'utf-8' - ) - - syncSystemConfigIntoManagedCodexHome() - - const state = readFileSync(getConfigSyncStatePath(), 'utf-8') - expect(state).toContain('lastMirrorableSystemConfigDigest') - expect(state).toMatch(/sha256:[a-f0-9]{64}/) - expect(state).not.toContain('sk-sensitive-value') - expect(state).not.toContain('api_key') - }) - - it('migrates legacy sync state without keeping sensitive system config contents', () => { - const systemConfig = 'model = "system-model"\napi_key = "sk-sensitive-value"\n' - mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) - writeFileSync(getRuntimeConfigPath(), systemConfig, 'utf-8') - writeFileSync(getSystemConfigPath(), systemConfig, 'utf-8') - writeFileSync( - getConfigSyncStatePath(), - `${JSON.stringify({ lastSystemConfig: systemConfig }, null, 2)}\n`, - 'utf-8' - ) - - syncSystemConfigIntoManagedCodexHome() - - const state = readFileSync(getConfigSyncStatePath(), 'utf-8') - expect(state).toContain('lastMirrorableSystemConfigDigest') - expect(state).toMatch(/sha256:[a-f0-9]{64}/) - expect(state).not.toContain('sk-sensitive-value') - expect(state).not.toContain('api_key') - }) - - it('normalizes legacy codex_hooks sync state before comparing digests', () => { - const systemConfig = [ - 'model = "system-model"', - '', - '[features]', - 'codex_hooks = true', - '' - ].join('\n') - mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) - writeFileSync( - getRuntimeConfigPath(), - ['model = "runtime-model"', '', '[features]', 'hooks = true', ''].join('\n'), - 'utf-8' - ) - writeFileSync(getSystemConfigPath(), systemConfig, 'utf-8') - writeFileSync( - getConfigSyncStatePath(), - `${JSON.stringify({ lastSystemConfig: systemConfig }, null, 2)}\n`, - 'utf-8' - ) - - syncSystemConfigIntoManagedCodexHome() - - const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') - expect(runtimeConfig).toContain('model = "runtime-model"') - expect(runtimeConfig).not.toContain('model = "system-model"') - expect(runtimeConfig).toContain('[features]\nhooks = true') - }) - it('does not treat TOML table headers inside multiline strings as sections', () => { - establishSystemConfigBaseline('model = "initial-system-model"\n') + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) writeFileSync( getRuntimeConfigPath(), [ @@ -1030,7 +216,7 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { }) it('does not let triple quotes in comments affect runtime-owned section mirroring', () => { - establishSystemConfigBaseline('model = "initial-system-model"\n') + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) writeFileSync( getRuntimeConfigPath(), [ diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index a7b4c0e85..3e35511bf 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -1,18 +1,7 @@ -/* eslint-disable max-lines -- Why: keeping Codex config merge policy beside -the TOML section scanner makes precedence between system config, runtime -preferences, and trust state auditable in one place. */ import { existsSync, readFileSync } from 'fs' import { join } from 'path' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' -import { - getSystemCodexConfigDigest, - readLastSyncedSystemCodexConfigState, - writeLastSyncedMirrorableSystemCodexConfigDigest, - writeLastSyncedMirrorableSystemCodexConfigDigestOnly, - writeLastSyncedMirrorableSystemCodexConfigDigestValue -} from './codex-config-sync-state' function getRuntimeCodexConfigTomlPath(): string { return join(getOrcaManagedCodexHomePath(), 'config.toml') @@ -42,163 +31,18 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void { const systemConfig = normalizeDeprecatedCodexHookFeatureFlag( systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : '' ) - const systemConfigUnits = getSystemConfigUnits(systemConfig) - const systemConfigUnitDigests = getSystemConfigUnitDigestRecord(systemConfigUnits) - const mirrorableSystemConfig = getMirrorableSystemCodexConfig(systemConfig) - const lastSyncedSystemConfig = readLastSyncedSystemCodexConfigState() - const lastSyncedMirrorableSystemConfig = - lastSyncedSystemConfig.status === 'legacy' - ? { - status: 'valid' as const, - digest: getSystemCodexConfigDigest( - getMirrorableSystemCodexConfig( - normalizeDeprecatedCodexHookFeatureFlag(lastSyncedSystemConfig.systemConfig) - ) - ), - unitDigests: getSystemConfigUnitDigestRecord( - getSystemConfigUnits( - normalizeDeprecatedCodexHookFeatureFlag(lastSyncedSystemConfig.systemConfig) - ) - ), - needsRewrite: true - } - : lastSyncedSystemConfig if (!runtimeConfigExists) { // Why: trust blocks reference a hooks.json path, so system-home hook trust // entries are not valid in Orca's runtime CODEX_HOME until install remaps them. writeFileAtomically(runtimeConfigPath, stripRuntimeOwnedTomlSections(systemConfig)) - writeLastSyncedMirrorableSystemCodexConfigDigest( - mirrorableSystemConfig, - systemConfigUnitDigests - ) - return - } - - if (!systemConfigExists) { - if (lastSyncedMirrorableSystemConfig.status !== 'valid') { - return - } - if (lastSyncedMirrorableSystemConfig.unitDigests === null) { - if (lastSyncedMirrorableSystemConfig.needsRewrite) { - writeLastSyncedMirrorableSystemCodexConfigDigestOnly( - lastSyncedMirrorableSystemConfig.digest - ) - } - return - } - if (lastSyncedMirrorableSystemConfig.needsRewrite) { - // Why: a migrated legacy state can still prove the last system content; - // scrub legacy raw config without treating temporary absence as deletion. - writeLastSyncedMirrorableSystemCodexConfigDigestValue( - lastSyncedMirrorableSystemConfig.digest, - lastSyncedMirrorableSystemConfig.unitDigests - ) - return - } - const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8') - const nextUnitDigests = getUnitDigestsAfterSystemConfigDeletion( - runtimeConfig, - lastSyncedMirrorableSystemConfig.unitDigests - ) - const mergedConfig = mergeChangedSystemConfigUnitsIntoRuntime( - runtimeConfig, - systemConfigUnits, - lastSyncedMirrorableSystemConfig.unitDigests - ) - if (mergedConfig !== runtimeConfig) { - writeFileAtomically(runtimeConfigPath, mergedConfig) - } - writeLastSyncedMirrorableSystemCodexConfigDigestValue( - getSystemCodexConfigDigest(mirrorableSystemConfig), - nextUnitDigests - ) - return - } - - if (lastSyncedMirrorableSystemConfig.status === 'missing') { - // Why: pre-state runtime configs may already contain Codex TUI preference - // changes written inside Orca's managed CODEX_HOME. Without a content - // baseline, preserve those ordinary prefs and only sync trust state. - const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8') - const mergedConfig = mergeSystemProjectTrustIntoRuntimeBaseline(runtimeConfig, systemConfig) - if (mergedConfig !== runtimeConfig) { - writeFileAtomically(runtimeConfigPath, mergedConfig) - } - writeLastSyncedMirrorableSystemCodexConfigDigest( - mirrorableSystemConfig, - systemConfigUnitDigests - ) - return - } - - if (lastSyncedMirrorableSystemConfig.status === 'invalid') { - const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8') - const mergedConfig = mergeSystemProjectTrustIntoRuntimeBaseline(runtimeConfig, systemConfig) - if (mergedConfig !== runtimeConfig) { - writeFileAtomically(runtimeConfigPath, mergedConfig) - } - // Why: corrupt sync state cannot prove the ordinary system settings were - // previously mirrored, so recover the baseline without overwriting TUI prefs. - writeLastSyncedMirrorableSystemCodexConfigDigest( - mirrorableSystemConfig, - systemConfigUnitDigests - ) - return - } - - if (lastSyncedMirrorableSystemConfig.unitDigests === null) { - const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8') - const currentMirrorableSystemConfigDigest = getSystemCodexConfigDigest(mirrorableSystemConfig) - const runtimeMirrorableConfigDigest = getSystemCodexConfigDigest( - getMirrorableSystemCodexConfig(runtimeConfig) - ) - if (lastSyncedMirrorableSystemConfig.digest === currentMirrorableSystemConfigDigest) { - const mergedConfig = mergeSystemProjectTrustIntoRuntimeBaseline(runtimeConfig, systemConfig) - if (mergedConfig !== runtimeConfig) { - writeFileAtomically(runtimeConfigPath, mergedConfig) - } - writeLastSyncedMirrorableSystemCodexConfigDigest( - mirrorableSystemConfig, - systemConfigUnitDigests - ) - return - } - if (lastSyncedMirrorableSystemConfig.digest === runtimeMirrorableConfigDigest) { - const mergedConfig = mergeChangedSystemConfigUnitsIntoRuntime( - runtimeConfig, - systemConfigUnits, - {} - ) - if (mergedConfig !== runtimeConfig) { - writeFileAtomically(runtimeConfigPath, mergedConfig) - } - writeLastSyncedMirrorableSystemCodexConfigDigest( - mirrorableSystemConfig, - systemConfigUnitDigests - ) - return - } - const mergedConfig = mergeSystemProjectTrustIntoRuntimeBaseline(runtimeConfig, systemConfig) - if (mergedConfig !== runtimeConfig) { - writeFileAtomically(runtimeConfigPath, mergedConfig) - } - writeLastSyncedMirrorableSystemCodexConfigDigest( - mirrorableSystemConfig, - systemConfigUnitDigests - ) return } const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8') - const mergedConfig = mergeChangedSystemConfigUnitsIntoRuntime( - runtimeConfig, - systemConfigUnits, - lastSyncedMirrorableSystemConfig.unitDigests - ) + const mergedConfig = mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig) if (mergedConfig !== runtimeConfig) { writeFileAtomically(runtimeConfigPath, mergedConfig) } - writeLastSyncedMirrorableSystemCodexConfigDigest(mirrorableSystemConfig, systemConfigUnitDigests) } function normalizeDeprecatedCodexHookFeatureFlag(config: string): string { @@ -265,390 +109,33 @@ function normalizeFeatureSectionLines(lines: string[], start: number, end: numbe } } -type SystemConfigUnit = { - key: string - stateKey: string - digest: string - block: string - kind: 'ordinary' | 'project' - placement: 'top-level' | 'section' -} - -function getSystemConfigUnits(config: string): SystemConfigUnit[] { - const ordinaryTopLevelUnits = getTopLevelTomlUnits(config).map((unit) => - createSystemConfigUnit(`top:${unit.key}`, unit.block, 'ordinary', 'top-level') - ) - const sectionIdentityCounts = new Map() - const sectionUnits: SystemConfigUnit[] = [] - for (const section of getTomlSections(config)) { - if (isRuntimeHookTrustTomlSection(section.header)) { - continue - } - const identityKey = getTomlSectionIdentityKey(section.header) - const occurrence = sectionIdentityCounts.get(identityKey) ?? 0 - sectionIdentityCounts.set(identityKey, occurrence + 1) - sectionUnits.push( - createSystemConfigUnit( - `section:${identityKey}:${occurrence}`, - section.block, - isRuntimeProjectTomlSection(section.header) ? 'project' : 'ordinary', - 'section' - ) - ) - } - return [...ordinaryTopLevelUnits, ...sectionUnits] -} - -function createSystemConfigUnit( - key: string, - block: string, - kind: SystemConfigUnit['kind'], - placement: SystemConfigUnit['placement'] -): SystemConfigUnit { - return { - key, - stateKey: getSystemCodexConfigDigest(key), - digest: getSystemCodexConfigDigest(normalizeTomlUnitForDigest(block)), - block, - kind, - placement - } -} - -function getSystemConfigUnitDigestRecord(units: SystemConfigUnit[]): Record { - return Object.fromEntries(units.map((unit) => [unit.stateKey, unit.digest])) -} - -type TomlTopLevelUnit = { - key: string - block: string -} - -function getTopLevelTomlUnits(config: string): TomlTopLevelUnit[] { - const lines = config.split('\n') - const firstSectionIndex = getTomlSections(config)[0]?.start ?? -1 - const topLevelLines = firstSectionIndex === -1 ? lines : lines.slice(0, firstSectionIndex) - const units: TomlTopLevelUnit[] = [] - let unitStart = -1 - let unitKey: string | null = null - let multilineState: TomlMultilineState = { basic: false, literal: false } - - for (let index = 0; index < topLevelLines.length; index += 1) { - const line = topLevelLines[index] ?? '' - const assignmentKey = isInsideTomlMultilineString(multilineState) - ? null - : getTomlAssignmentKey(line) - if (assignmentKey !== null) { - if (unitStart !== -1 && unitKey !== null) { - units.push({ - key: unitKey, - block: topLevelLines.slice(unitStart, index).join('\n') - }) - } - unitStart = index - unitKey = assignmentKey - } - multilineState = updateTomlMultilineState(multilineState, line) - } - - if (unitStart !== -1 && unitKey !== null) { - units.push({ - key: unitKey, - block: topLevelLines.slice(unitStart).join('\n') - }) - } - return units -} - -function getTomlAssignmentKey(line: string): string | null { - let mode: TomlMultilineMode = null - let index = 0 - while (index < line.length) { - if (mode === 'basic') { - if (line[index] === '\\') { - index += 2 - continue - } - if (line[index] === '"') { - mode = null - } - index += 1 - continue - } - if (mode === 'literal') { - if (line[index] === "'") { - mode = null - } - index += 1 - continue - } - const char = line[index] - if (char === '#') { - return null - } - if (char === '=') { - const key = line.slice(0, index).trim() - return key.length > 0 ? key : null - } - if (char === '"') { - mode = 'basic' - } else if (char === "'") { - mode = 'literal' - } - index += 1 - } - return null -} - -function normalizeTomlUnitForDigest(block: string): string { - let multilineState: TomlMultilineState = { basic: false, literal: false } - const lines: string[] = [] - for (const line of block.split('\n')) { - const normalizedLine = isInsideTomlMultilineString(multilineState) - ? line.trim() - : normalizeTomlStructuralLineForDigest(line) - if (normalizedLine.length > 0) { - lines.push(normalizedLine) - } - multilineState = updateTomlMultilineState(multilineState, line) - } - return lines.join('\n') -} - -function normalizeTomlStructuralLineForDigest(line: string): string { - const header = getTomlTableHeader(line) - const table = header ? parseTomlTableHeaderPath(header) : null - if (table) { - return getCanonicalTomlTableIdentity(table) - } - return stripTomlLineComment(line) - .trim() - .replace(/[ \t]*=[ \t]*/, ' = ') -} - -function stripTomlLineComment(line: string): string { - let mode: TomlMultilineMode = null - let index = 0 - while (index < line.length) { - if (mode === 'basic') { - if (line[index] === '\\') { - index += 2 - continue - } - if (line[index] === '"') { - mode = null - } - index += 1 - continue - } - if (mode === 'literal') { - if (line[index] === "'") { - mode = null - } - index += 1 - continue - } - const char = line[index] - if (char === '#') { - return line.slice(0, index) - } - if (char === '"') { - mode = 'basic' - } else if (char === "'") { - mode = 'literal' - } - index += 1 - } - return line -} - -function mergeChangedSystemConfigUnitsIntoRuntime( - runtimeConfig: string, - systemUnits: SystemConfigUnit[], - previousUnitDigests: Record -): string { - const runtimeUnits = getSystemConfigUnits(runtimeConfig) - const runtimeStateKeys = new Set(runtimeUnits.map((unit) => unit.stateKey)) - const systemStateKeys = new Set(systemUnits.map((unit) => unit.stateKey)) - const changedSystemStateKeys = new Set( - systemUnits - .filter( - (unit) => - previousUnitDigests[unit.stateKey] !== unit.digest || - (unit.kind === 'project' && !runtimeStateKeys.has(unit.stateKey)) - ) - .map((unit) => unit.stateKey) - ) - const removedSystemStateKeys = new Set( - Object.keys(previousUnitDigests).filter((stateKey) => !systemStateKeys.has(stateKey)) - ) - const runtimeHookSections = getTomlSections(runtimeConfig) - .filter((section) => isRuntimeHookTrustTomlSection(section.header)) - .map((section) => section.block) - const changedSystemUnitByStateKey = new Map( - systemUnits - .filter((unit) => changedSystemStateKeys.has(unit.stateKey)) - .map((unit) => [ - unit.stateKey, - unit.kind === 'project' ? getProjectUnitWithRuntimeOwnedFields(unit, runtimeUnits) : unit - ]) - ) - const consumedChangedSystemStateKeys = new Set() - const outputUnits: SystemConfigUnit[] = [] - for (const runtimeUnit of runtimeUnits) { - const changedSystemUnit = changedSystemUnitByStateKey.get(runtimeUnit.stateKey) - if (changedSystemUnit) { - outputUnits.push(changedSystemUnit) - consumedChangedSystemStateKeys.add(runtimeUnit.stateKey) - continue - } - const previousDigest = previousUnitDigests[runtimeUnit.stateKey] - const shouldRemoveUnchangedSystemUnit = - previousDigest !== undefined && - removedSystemStateKeys.has(runtimeUnit.stateKey) && - previousDigest === runtimeUnit.digest - if (!shouldRemoveUnchangedSystemUnit) { - outputUnits.push(runtimeUnit) - } - } - outputUnits.push( - ...[...changedSystemUnitByStateKey] - .filter(([stateKey]) => !consumedChangedSystemStateKeys.has(stateKey)) - .map(([, unit]) => unit) - ) - return joinTomlBlocks([ - ...outputUnits.filter((unit) => unit.placement === 'top-level').map((unit) => unit.block), - ...outputUnits.filter((unit) => unit.placement === 'section').map((unit) => unit.block), - ...runtimeHookSections - ]) -} - -function getProjectUnitWithRuntimeOwnedFields( - systemUnit: SystemConfigUnit, - runtimeUnits: SystemConfigUnit[] -): SystemConfigUnit { - const runtimeUnit = runtimeUnits.find((unit) => unit.stateKey === systemUnit.stateKey) - if (!runtimeUnit) { - return systemUnit - } - return { - ...systemUnit, - block: mergeProjectTrustAssignmentIntoRuntimeBlock(runtimeUnit.block, systemUnit.block) - } -} - -function mergeProjectTrustAssignmentIntoRuntimeBlock( - runtimeBlock: string, - systemBlock: string -): string { - const systemTrustLine = getProjectTrustLine(systemBlock) - if (!systemTrustLine) { - return runtimeBlock - } - - const lines = runtimeBlock.split('\n') - const trustLineIndexes = lines - .map((line, index) => (isProjectTrustAssignmentLine(line) ? index : -1)) - .filter((index) => index !== -1) - if (trustLineIndexes.length === 0) { - lines.splice(1, 0, systemTrustLine) - return lines.join('\n') - } - - lines[trustLineIndexes[0]!] = systemTrustLine - for (const index of trustLineIndexes.slice(1).reverse()) { - lines.splice(index, 1) - } - return lines.join('\n') -} - -function getProjectTrustLine(block: string): string | null { - return block.split('\n').find((line) => isProjectTrustAssignmentLine(line)) ?? null -} - -function isProjectTrustAssignmentLine(line: string): boolean { - return /^[ \t]*trust_level[ \t]*=/.test(line) && getProjectTrustLevel(`x = 1\n${line}\n`) !== null -} - -function getUnitDigestsAfterSystemConfigDeletion( - runtimeConfig: string, - previousUnitDigests: Record -): Record { - return Object.fromEntries( - getSystemConfigUnits(runtimeConfig) - .map((unit) => [unit.stateKey, previousUnitDigests[unit.stateKey], unit.digest] as const) - .filter( - ([, previousDigest, runtimeDigest]) => - previousDigest !== undefined && previousDigest !== runtimeDigest - ) - .map(([stateKey, previousDigest]) => [stateKey, previousDigest]) - ) -} - -function getMirrorableSystemCodexConfig(systemConfig: string): string { - return joinTomlBlocks( - getSystemConfigUnits(systemConfig) - .filter((unit) => unit.kind === 'ordinary') - .map((unit) => unit.block) - ) -} - -function mergeSystemProjectTrustIntoRuntimeBaseline( - runtimeConfig: string, - systemConfig: string -): string { +function mergeSystemCodexConfigIntoRuntime(runtimeConfig: string, systemConfig: string): string { const runtimeSections = getTomlSections(runtimeConfig) const runtimeProjectHeaders = new Set( runtimeSections .filter((section) => isRuntimeProjectTomlSection(section.header)) - .map((section) => getTomlSectionIdentityKey(section.header)) + .map((section) => getTomlSectionHeaderKey(section.header)) ) - const systemProjectSections = getTomlSections(systemConfig).filter((section) => - isRuntimeProjectTomlSection(section.header) + const systemUntrustedProjectHeaders = new Set( + getTomlSections(systemConfig) + .filter((section) => isRuntimeProjectTomlSection(section.header)) + .filter((section) => getProjectTrustLevel(section.block) === 'untrusted') + .map((section) => getTomlSectionHeaderKey(section.header)) ) - const systemProjectSectionsByHeader = new Map( - systemProjectSections.map((section) => [getTomlSectionIdentityKey(section.header), section]) - ) - const systemProjectSectionsToAppend = systemProjectSections.filter( - (section) => !runtimeProjectHeaders.has(getTomlSectionIdentityKey(section.header)) - ) - const hasExplicitTrustToMerge = systemProjectSections.some( - (section) => - runtimeProjectHeaders.has(getTomlSectionIdentityKey(section.header)) && - getProjectTrustLevel(section.block) !== null - ) - if (systemProjectSectionsToAppend.length === 0 && !hasExplicitTrustToMerge) { - return runtimeConfig - } - - const systemExplicitTrustProjectHeaders = new Set( - systemProjectSections - .filter((section) => getProjectTrustLevel(section.block) !== null) - .map((section) => getTomlSectionIdentityKey(section.header)) - ) - const lines = runtimeConfig.split('\n') - const firstSectionIndex = runtimeSections[0]?.start ?? -1 - const preamble = - firstSectionIndex === -1 ? runtimeConfig : lines.slice(0, firstSectionIndex).join('\n') - // Why: when the baseline is missing, Orca cannot safely decide whether - // ordinary settings changed in system or runtime config. Project trust is - // safety-sensitive, so still honor explicit system revocations. + // Why: ordinary Codex settings should mirror ~/.codex exactly; runtime hook + // trust and project trust are written under Orca's managed CODEX_HOME and + // must survive the copy unless the user explicitly revoked project trust in + // the system config. return joinTomlBlocks([ - preamble, - ...runtimeSections.map((section) => { - const identityKey = getTomlSectionIdentityKey(section.header) - if ( - !isRuntimeProjectTomlSection(section.header) || - !systemExplicitTrustProjectHeaders.has(identityKey) - ) { - return section.block - } - const systemSection = systemProjectSectionsByHeader.get(identityKey) - return systemSection - ? mergeProjectTrustAssignmentIntoRuntimeBlock(section.block, systemSection.block) - : section.block - }), - ...systemProjectSectionsToAppend.map((section) => section.block) + stripRuntimeOwnedTomlSections(systemConfig, runtimeProjectHeaders), + ...runtimeSections + .filter((section) => isRuntimePreservedTomlSection(section.header)) + .filter( + (section) => + !isRuntimeProjectTomlSection(section.header) || + !systemUntrustedProjectHeaders.has(getTomlSectionHeaderKey(section.header)) + ) + .map((section) => section.block) ]) } @@ -680,7 +167,7 @@ function stripRuntimeOwnedTomlSections( .filter( (section) => !isRuntimeProjectTomlSection(section.header) || - !runtimeProjectHeaders.has(getTomlSectionIdentityKey(section.header)) || + !runtimeProjectHeaders.has(getTomlSectionHeaderKey(section.header)) || getProjectTrustLevel(section.block) === 'untrusted' ) .map((section) => section.block) @@ -725,99 +212,20 @@ function getTomlSections(config: string): TomlSection[] { return sections } +function isRuntimePreservedTomlSection(header: string): boolean { + return isRuntimeHookTrustTomlSection(header) || isRuntimeProjectTomlSection(header) +} + function isRuntimeHookTrustTomlSection(header: string): boolean { - const table = parseTomlTableHeaderPath(header) - return table?.parts[0] === 'hooks' && table.parts[1] === 'state' && table.parts.length > 2 + return header.trimStart().startsWith('[hooks.state.') } function isRuntimeProjectTomlSection(header: string): boolean { - const table = parseTomlTableHeaderPath(header) - return table?.parts[0] === 'projects' && table.parts.length > 1 + return header.trimStart().startsWith('[projects.') } -function getTomlSectionIdentityKey(header: string): string { - const table = parseTomlTableHeaderPath(header) - if (!table) { - return header.trim() - } - return getCanonicalTomlTableIdentity(table) -} - -function getCanonicalTomlTableIdentity(table: { array: boolean; parts: string[] }): string { - if (table.parts[0] === 'projects' && table.parts.length > 1) { - return `project:${normalizeRuntimePathForComparison(table.parts.slice(1).join('.'))}` - } - return JSON.stringify({ array: table.array, parts: table.parts }) -} - -function parseTomlTableHeaderPath(header: string): { array: boolean; parts: string[] } | null { - const trimmed = header.trim() - const arrayMatch = /^\[\[\s*(.*?)\s*\]\]$/.exec(trimmed) - const tableMatch = /^\[\s*(.*?)\s*\]$/.exec(trimmed) - const keyPath = arrayMatch?.[1] ?? tableMatch?.[1] - if (keyPath === undefined) { - return null - } - const parts = splitTomlDottedKeyPath(keyPath) - .map((part) => parseTomlHeaderKeyPart(part.trim())) - .filter((part): part is string => part !== null) - return parts.length > 0 ? { array: arrayMatch !== null, parts } : null -} - -function splitTomlDottedKeyPath(keyPath: string): string[] { - const parts: string[] = [] - let mode: TomlMultilineMode = null - let partStart = 0 - let index = 0 - while (index < keyPath.length) { - if (mode === 'basic') { - if (keyPath[index] === '\\') { - index += 2 - continue - } - if (keyPath[index] === '"') { - mode = null - } - index += 1 - continue - } - if (mode === 'literal') { - if (keyPath[index] === "'") { - mode = null - } - index += 1 - continue - } - if (keyPath[index] === '"') { - mode = 'basic' - } else if (keyPath[index] === "'") { - mode = 'literal' - } else if (keyPath[index] === '.') { - parts.push(keyPath.slice(partStart, index)) - partStart = index + 1 - } - index += 1 - } - parts.push(keyPath.slice(partStart)) - return parts -} - -function parseTomlHeaderKeyPart(keyPart: string): string | null { - if (keyPart.startsWith('"') && keyPart.endsWith('"')) { - return parseTomlBasicStringValue(keyPart) - } - if (keyPart.startsWith("'") && keyPart.endsWith("'")) { - return keyPart.slice(1, -1) - } - return keyPart.length > 0 ? keyPart : null -} - -function parseTomlBasicStringValue(value: string): string | null { - try { - return JSON.parse(value) as string - } catch { - return null - } +function getTomlSectionHeaderKey(header: string): string { + return header.trim() } function getProjectTrustLevel(block: string): 'trusted' | 'untrusted' | null { diff --git a/src/main/codex/codex-config-sync-state.ts b/src/main/codex/codex-config-sync-state.ts deleted file mode 100644 index 92550cb8c..000000000 --- a/src/main/codex/codex-config-sync-state.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { createHash } from 'node:crypto' -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { writeFileAtomically } from '../codex-accounts/fs-utils' -import { getOrcaManagedCodexHomePath } from './codex-home-paths' - -type CodexConfigSyncState = { - lastMirrorableSystemConfigDigest: string - lastSystemConfigUnitDigests: Record -} - -type CodexConfigSyncStateRead = - | { - status: 'valid' - digest: string - unitDigests: Record | null - needsRewrite: boolean - } - | { - status: 'legacy' - systemConfig: string - } - | { - status: 'missing' - } - | { - status: 'invalid' - } - -const SYSTEM_CONFIG_DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/ - -function getCodexConfigSyncStatePath(): string { - return join(dirname(getOrcaManagedCodexHomePath()), 'config-sync-state.json') -} - -export function getSystemCodexConfigDigest(systemConfig: string): string { - return `sha256:${createHash('sha256').update(systemConfig).digest('hex')}` -} - -export function readLastSyncedSystemCodexConfigState(): CodexConfigSyncStateRead { - try { - const parsed = JSON.parse(readFileSync(getCodexConfigSyncStatePath(), 'utf-8')) as unknown - const isStateObject = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) - const hasLegacySystemConfig = isStateObject && Object.hasOwn(parsed, 'lastSystemConfig') - const lastSystemConfig = - isStateObject && - typeof (parsed as { lastSystemConfig?: unknown }).lastSystemConfig === 'string' - ? (parsed as { lastSystemConfig: string }).lastSystemConfig - : null - const lastMirrorableSystemConfigDigest = - isStateObject && - typeof (parsed as { lastMirrorableSystemConfigDigest?: unknown }) - .lastMirrorableSystemConfigDigest === 'string' - ? (parsed as CodexConfigSyncState).lastMirrorableSystemConfigDigest - : null - const legacySystemConfigDigest = - isStateObject && - typeof (parsed as { lastSystemConfigDigest?: unknown }).lastSystemConfigDigest === 'string' - ? (parsed as { lastSystemConfigDigest: string }).lastSystemConfigDigest - : null - const effectiveSystemConfigDigest = lastMirrorableSystemConfigDigest ?? legacySystemConfigDigest - const lastSystemConfigUnitDigests = - isStateObject && - isValidDigestRecord( - (parsed as { lastSystemConfigUnitDigests?: unknown }).lastSystemConfigUnitDigests - ) - ? ((parsed as CodexConfigSyncState).lastSystemConfigUnitDigests ?? null) - : null - if ( - effectiveSystemConfigDigest !== null && - SYSTEM_CONFIG_DIGEST_PATTERN.test(effectiveSystemConfigDigest) - ) { - return { - status: 'valid', - digest: effectiveSystemConfigDigest, - unitDigests: lastSystemConfigUnitDigests, - needsRewrite: - hasLegacySystemConfig || - lastSystemConfigUnitDigests === null || - legacySystemConfigDigest !== null - } - } - if (lastSystemConfig !== null) { - return { - status: 'legacy', - systemConfig: lastSystemConfig - } - } - } catch (error) { - return (error as NodeJS.ErrnoException).code === 'ENOENT' - ? { status: 'missing' } - : { status: 'invalid' } - } - return { status: 'invalid' } -} - -function isValidDigestRecord(value: unknown): value is Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false - } - return Object.entries(value).every( - ([key, digest]) => - SYSTEM_CONFIG_DIGEST_PATTERN.test(key) && - typeof digest === 'string' && - SYSTEM_CONFIG_DIGEST_PATTERN.test(digest) - ) -} - -export function writeLastSyncedMirrorableSystemCodexConfigDigest( - mirrorableSystemConfig: string, - unitDigests: Record -): void { - writeLastSyncedMirrorableSystemCodexConfigDigestValue( - getSystemCodexConfigDigest(mirrorableSystemConfig), - unitDigests - ) -} - -export function writeLastSyncedMirrorableSystemCodexConfigDigestValue( - digest: string, - unitDigests: Record -): void { - if (!SYSTEM_CONFIG_DIGEST_PATTERN.test(digest)) { - throw new Error('Invalid Codex config digest') - } - if (!isValidDigestRecord(unitDigests)) { - throw new Error('Invalid Codex config unit digests') - } - writeConfigSyncState({ - lastMirrorableSystemConfigDigest: digest, - lastSystemConfigUnitDigests: unitDigests - }) -} - -export function writeLastSyncedMirrorableSystemCodexConfigDigestOnly(digest: string): void { - if (!SYSTEM_CONFIG_DIGEST_PATTERN.test(digest)) { - throw new Error('Invalid Codex config digest') - } - writeConfigSyncState({ lastMirrorableSystemConfigDigest: digest }) -} - -function writeConfigSyncState(state: { - lastMirrorableSystemConfigDigest: string - lastSystemConfigUnitDigests?: Record -}): void { - // Why: config.toml can contain provider credentials; a digest is enough to - // detect user edits without persisting a second copy of the config. - writeFileAtomically(getCodexConfigSyncStatePath(), `${JSON.stringify(state, null, 2)}\n`, { - mode: 0o600 - }) -} diff --git a/src/main/codex/codex-launch-home-paths.test.ts b/src/main/codex/codex-launch-home-paths.test.ts deleted file mode 100644 index 886598cb6..000000000 --- a/src/main/codex/codex-launch-home-paths.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - existsSync, - lstatSync, - mkdtempSync, - mkdirSync, - readFileSync, - rmSync, - statSync, - writeFileSync -} from 'node:fs' -import type * as NodeFs from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -const { fsMockState } = vi.hoisted(() => ({ - fsMockState: { - failSymlink: false, - hardLinkRace: false, - failSqliteRead: false, - readFilePaths: [] as string[] - } -})) - -vi.mock('node:fs', async () => { - const actual = await vi.importActual('node:fs') - return { - ...actual, - linkSync: (...args: Parameters) => { - if (fsMockState.hardLinkRace) { - fsMockState.hardLinkRace = false - actual.linkSync(...args) - const error = new Error('target already linked') as NodeJS.ErrnoException - error.code = 'EEXIST' - throw error - } - return actual.linkSync(...args) - }, - readFileSync: ((...args: Parameters) => { - const targetPath = String(args[0]) - fsMockState.readFilePaths.push(targetPath) - if ( - fsMockState.failSqliteRead && - /(?:\.sqlite|\.sqlite-wal|\.sqlite-shm)$/.test(targetPath) - ) { - throw new Error(`unexpected sqlite content read: ${targetPath}`) - } - return actual.readFileSync(...args) - }) as typeof actual.readFileSync, - symlinkSync: (...args: Parameters) => { - if (fsMockState.failSymlink) { - throw new Error('symlink disabled for test') - } - return actual.symlinkSync(...args) - } - } -}) - -import { materializeScopedCodexLaunchHome } from './codex-launch-home-paths' - -let tempDir: string -let sharedHomePath: string -let launchRootPath: string - -function expectSameFile(targetPath: string, sourcePath: string): void { - const targetStat = statSync(targetPath) - const sourceStat = statSync(sourcePath) - expect(targetStat.dev).toBe(sourceStat.dev) - expect(targetStat.ino).toBe(sourceStat.ino) - expect(targetStat.nlink).toBeGreaterThan(1) -} - -function withWin32Platform(callback: () => T): T { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - try { - return callback() - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - } -} - -beforeEach(() => { - fsMockState.failSymlink = false - fsMockState.hardLinkRace = false - fsMockState.failSqliteRead = false - fsMockState.readFilePaths = [] - tempDir = mkdtempSync(join(tmpdir(), 'orca-codex-launch-home-')) - sharedHomePath = join(tempDir, 'shared-home') - launchRootPath = join(tempDir, 'launch-root') - mkdirSync(sharedHomePath, { recursive: true }) - mkdirSync(launchRootPath, { recursive: true }) -}) - -afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }) - vi.clearAllMocks() -}) - -describe('materializeScopedCodexLaunchHome', () => { - it('hard-links shared files when symlinks are unavailable', () => { - fsMockState.failSymlink = true - const hooksPath = join(sharedHomePath, 'hooks.json') - const sqlitePath = join(sharedHomePath, 'logs_2.sqlite') - const sqliteWalPath = join(sharedHomePath, 'logs_2.sqlite-wal') - const sqliteShmPath = join(sharedHomePath, 'logs_2.sqlite-shm') - writeFileSync(hooksPath, '{"hooks":{}}\n') - writeFileSync(sqlitePath, 'sqlite\n') - writeFileSync(sqliteWalPath, 'wal\n') - writeFileSync(sqliteShmPath, 'shm\n') - - const launchHomePath = withWin32Platform(() => - materializeScopedCodexLaunchHome(sharedHomePath, launchRootPath, null) - ) - - for (const entryName of [ - 'hooks.json', - 'logs_2.sqlite', - 'logs_2.sqlite-wal', - 'logs_2.sqlite-shm' - ]) { - const targetPath = join(launchHomePath, entryName) - const sourcePath = join(sharedHomePath, entryName) - expect(existsSync(targetPath)).toBe(true) - expect(lstatSync(targetPath).isSymbolicLink()).toBe(false) - expect(readFileSync(targetPath, 'utf-8')).toBe(readFileSync(sourcePath, 'utf-8')) - expectSameFile(targetPath, sourcePath) - } - }) - - it('accepts a shared file already linked by a concurrent materializer', () => { - fsMockState.hardLinkRace = true - const sourcePath = join(sharedHomePath, 'memories_1.sqlite-wal') - writeFileSync(sourcePath, 'wal\n') - - const launchHomePath = withWin32Platform(() => - materializeScopedCodexLaunchHome(sharedHomePath, launchRootPath, null) - ) - const targetPath = join(launchHomePath, 'memories_1.sqlite-wal') - - expect(existsSync(targetPath)).toBe(true) - expect(lstatSync(targetPath).isSymbolicLink()).toBe(false) - expect(readFileSync(targetPath, 'utf-8')).toBe(readFileSync(sourcePath, 'utf-8')) - expectSameFile(targetPath, sourcePath) - }) - - it('does not hash sqlite contents when marking shared database links', () => { - const sourcePath = join(sharedHomePath, 'logs_2.sqlite') - writeFileSync(sourcePath, 'sqlite\n') - fsMockState.failSqliteRead = true - - const launchHomePath = materializeScopedCodexLaunchHome(sharedHomePath, launchRootPath, null) - const targetPath = join(launchHomePath, 'logs_2.sqlite') - const markerPath = join(launchHomePath, '.orca-launch-home-links', 'logs_2.sqlite.json') - - expect(existsSync(targetPath)).toBe(true) - expect( - fsMockState.readFilePaths.some( - (readPath) => readPath === sourcePath || readPath === targetPath - ) - ).toBe(false) - expect(JSON.parse(readFileSync(markerPath, 'utf-8'))).toMatchObject({ - sourceDigest: null, - targetDigest: null - }) - }) -}) diff --git a/src/main/codex/codex-launch-home-paths.ts b/src/main/codex/codex-launch-home-paths.ts deleted file mode 100644 index ca94f887b..000000000 --- a/src/main/codex/codex-launch-home-paths.ts +++ /dev/null @@ -1,895 +0,0 @@ -/* eslint-disable max-lines -- Why: launch-home materialization needs path -safety, link/copy fallback, reconciliation, and cleanup in one place so -auth-only account isolation cannot drift across platforms. */ -import { - cpSync, - existsSync, - lstatSync, - linkSync, - mkdirSync, - readFileSync, - readlinkSync, - readdirSync, - renameSync, - rmdirSync, - rmSync, - statSync, - symlinkSync, - unlinkSync, - writeFileSync -} from 'node:fs' -import { execFileSync } from 'node:child_process' -import { createHash } from 'node:crypto' -import { dirname, isAbsolute, join, relative, resolve } from 'node:path' -import { parseWslUncPath } from '../../shared/wsl-paths' -import { getOrcaManagedCodexHomePath } from './codex-home-paths' - -const LAUNCH_HOME_MARKER = '.orca-managed-launch-home' -const LAUNCH_HOME_LINK_MARKERS_DIR = '.orca-launch-home-links' -const LAUNCH_HOME_MARKER_VERSION = 1 -const SHARED_LAUNCH_ENTRY_NAMES = new Set([ - 'config.toml', - 'hooks.json', - 'history.jsonl', - 'sessions', - 'skills', - 'plugins', - 'plugin-state', - 'profile-v2', - 'themes', - 'prompts' -]) -const MUTABLE_SHARED_FILE_ENTRIES = new Set([ - 'config.toml', - 'hooks.json', - 'history.jsonl', - 'profile-v2' -]) -const MUTABLE_SHARED_DIRECTORY_ENTRIES = new Set(['sessions', 'plugin-state', 'profile-v2']) - -type LaunchEntryMarker = { - version: number - sourcePath: string - mode: 'link' | 'copy' - targetDigest: string | null - sourceDigest: string | null -} - -export function getOrcaCodexLaunchHomePath(accountId: string | null): string { - return getScopedCodexLaunchHomePath(getOrcaCodexLaunchHostRootPath(), accountId) -} - -export function ensureOrcaCodexLaunchHome(accountId: string | null): string { - return ensureScopedCodexLaunchHome(getOrcaCodexLaunchHostRootPath(), accountId) -} - -export function materializeOrcaCodexLaunchHome(accountId: string | null): string { - return materializeScopedCodexLaunchHome( - getOrcaManagedCodexHomePath(), - getOrcaCodexLaunchHostRootPath(), - accountId - ) -} - -export function removeOrcaCodexLaunchHome(accountId: string): void { - removeScopedCodexLaunchHome( - getOrcaCodexLaunchHostRootPathWithOptions({ create: false }), - accountId - ) -} - -export function materializeOrcaCodexActiveHome(launchHomePath: string): string { - return pointActiveCodexHomeAtLaunchHome(getOrcaCodexActiveHostHomePath(), launchHomePath) -} - -export function pointActiveCodexHomeAtLaunchHome( - activeHomePath: string, - launchHomePath: string -): string { - if (pointWslActiveCodexHomeAtLaunchHome(activeHomePath, launchHomePath)) { - return activeHomePath - } - mkdirSync(dirname(activeHomePath), { recursive: true }) - if (activeHomeAlreadyPointsToLaunchHome(activeHomePath, launchHomePath)) { - return activeHomePath - } - - const nextLinkPath = `${activeHomePath}.next-${process.pid}-${Date.now()}` - removeActiveHomeLinkIfOwned(nextLinkPath) - try { - createSharedEntryLink(launchHomePath, nextLinkPath) - replaceActiveHomeLink(activeHomePath, nextLinkPath) - return activeHomePath - } catch (error) { - removeActiveHomeLinkIfOwned(nextLinkPath) - console.warn('[codex-home] Failed to point active Codex home at launch home:', error) - return launchHomePath - } -} - -function pointWslActiveCodexHomeAtLaunchHome( - activeHomePath: string, - launchHomePath: string -): boolean { - const paths = getSameDistroWslPaths(launchHomePath, activeHomePath) - if (!paths) { - return false - } - const nextLinuxPath = `${paths.targetLinuxPath}.next-${process.pid}-${Date.now()}` - try { - execFileSync( - 'wsl.exe', - [ - '-d', - paths.distro, - '--', - 'bash', - '-lc', - 'mkdir -p "$(dirname "$2")" && rm -f -- "$3" && ln -s -- "$1" "$3" && mv -Tf -- "$3" "$2"', - 'sh', - paths.sourceLinuxPath, - paths.targetLinuxPath, - nextLinuxPath - ], - { - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 5000 - } - ) - return true - } catch { - return false - } -} - -export function getScopedCodexLaunchHomePath( - launchRootPath: string, - accountId: string | null -): string { - const launchHomePath = resolveCodexLaunchHomePath(launchRootPath, accountId) - mkdirSync(launchHomePath, { recursive: true }) - return launchHomePath -} - -export function ensureScopedCodexLaunchHome( - launchRootPath: string, - accountId: string | null -): string { - const launchHomePath = getScopedCodexLaunchHomePath(launchRootPath, accountId) - writeLaunchHomeMarker(launchHomePath, accountId) - return launchHomePath -} - -export function materializeScopedCodexLaunchHome( - sharedHomePath: string, - launchRootPath: string, - accountId: string | null -): string { - reconcileMutableLaunchHomeFilesIntoSharedHome(sharedHomePath, launchRootPath) - const launchHomePath = getScopedCodexLaunchHomePath(launchRootPath, accountId) - writeLaunchHomeMarker(launchHomePath, accountId) - - const sharedEntries = new Set() - for (const entryName of listSharedLaunchEntryNames(sharedHomePath)) { - sharedEntries.add(entryName) - linkSharedEntryIntoLaunchHome(sharedHomePath, launchHomePath, entryName) - } - removeStaleLaunchHomeEntries(launchHomePath, sharedHomePath, sharedEntries) - return launchHomePath -} - -export function removeScopedCodexLaunchHome(launchRootPath: string, accountId: string): void { - const launchHomePath = resolveCodexLaunchHomePath(launchRootPath, accountId) - if (!existsSync(launchHomePath)) { - return - } - const launchHomeStat = lstatSync(launchHomePath) - if (!launchHomeStat.isDirectory() || launchHomeStat.isSymbolicLink()) { - console.warn('[codex-home] Refusing to remove unexpected launch-home root:', launchHomePath) - return - } - if (!isMarkedLaunchHomeForAccount(launchHomePath, accountId)) { - // Why: older builds could write auth before the launch-home marker existed. - // Remove only the deterministic credential file, not an unmarked directory. - rmSync(join(launchHomePath, 'auth.json'), { force: true }) - return - } - if (!isContainedPath(launchRootPath, launchHomePath)) { - console.warn('[codex-home] Refusing to remove launch home outside host root:', launchHomePath) - return - } - if (removeWslPathIfPossible(launchHomePath)) { - return - } - rmSync(launchHomePath, { recursive: true, force: true }) -} - -function getOrcaCodexLaunchHostRootPath(): string { - return getOrcaCodexLaunchHostRootPathWithOptions({ create: true }) -} - -function getOrcaCodexLaunchHostRootPathWithOptions(options: { create: boolean }): string { - const rootPath = join(dirname(getOrcaManagedCodexHomePath()), 'launch', 'host') - if (options.create) { - mkdirSync(rootPath, { recursive: true }) - } - return rootPath -} - -function getOrcaCodexActiveHostHomePath(): string { - return join(dirname(getOrcaManagedCodexHomePath()), 'active', 'host', 'home') -} - -function resolveCodexLaunchHomePath(launchRootPath: string, accountId: string | null): string { - return join(launchRootPath, getLaunchSelectionSegment(accountId), 'home') -} - -function getLaunchSelectionSegment(accountId: string | null): string { - if (accountId === null) { - return 'system' - } - return `account-${createHash('sha256').update(accountId).digest('hex').slice(0, 32)}` -} - -function listSharedLaunchEntryNames(sharedHomePath: string): string[] { - try { - const sharedEntries = new Set() - for (const entryName of readdirSync(sharedHomePath)) { - if (!isSharedLaunchEntryName(entryName)) { - continue - } - sharedEntries.add(entryName) - if (entryName.endsWith('.sqlite')) { - sharedEntries.add(`${entryName}-wal`) - sharedEntries.add(`${entryName}-shm`) - } - } - return [...sharedEntries].sort() - } catch { - return [] - } -} - -function isSharedLaunchEntryName(entryName: string): boolean { - return SHARED_LAUNCH_ENTRY_NAMES.has(entryName) || isCodexSqliteEntryName(entryName) -} - -function isCodexSqliteEntryName(entryName: string): boolean { - return ( - entryName.endsWith('.sqlite') || - entryName.endsWith('.sqlite-wal') || - entryName.endsWith('.sqlite-shm') - ) -} - -function isCodexSqliteSidecarEntryName(entryName: string): boolean { - return entryName.endsWith('.sqlite-wal') || entryName.endsWith('.sqlite-shm') -} - -function getCodexSqliteMainEntryName(entryName: string): string | null { - if (entryName.endsWith('.sqlite-wal')) { - return entryName.slice(0, -'-wal'.length) - } - if (entryName.endsWith('.sqlite-shm')) { - return entryName.slice(0, -'-shm'.length) - } - return null -} - -function linkSharedEntryIntoLaunchHome( - sharedHomePath: string, - launchHomePath: string, - entryName: string -): void { - const sourcePath = join(sharedHomePath, entryName) - const targetPath = join(launchHomePath, entryName) - const existingMarker = readLaunchEntryMarker(launchHomePath, entryName) - reconcileMutableLaunchEntryIfNeeded(sourcePath, targetPath, existingMarker) - - if (!existsSync(sourcePath) && !canLinkMissingSharedEntry(sharedHomePath, entryName)) { - removeLaunchEntryIfOwned(targetPath, launchHomePath, entryName, sourcePath) - return - } - materializeMissingSharedEntryIfNeeded(sourcePath, entryName) - if (targetAlreadyPointsToSource(targetPath, sourcePath)) { - markLaunchEntry(launchHomePath, entryName, sourcePath, 'link') - return - } - - const ownedTarget = - existingMarker?.sourcePath === sourcePath && targetExistsForLaunchRemoval(targetPath) - if (targetExistsForLaunchRemoval(targetPath) && !ownedTarget) { - if (!replaceUnownedLaunchEntryAllowed(launchHomePath, entryName)) { - return - } - removeLaunchEntry(targetPath) - } - if (ownedTarget) { - removeLaunchEntry(targetPath) - } - - try { - createSharedEntryLink(sourcePath, targetPath) - markLaunchEntry(launchHomePath, entryName, sourcePath, 'link') - } catch (error) { - if (!copyFallbackAllowed(sourcePath, entryName)) { - console.warn('[codex-home] Failed to link shared Codex launch entry:', entryName, error) - return - } - try { - removeLaunchEntry(targetPath) - cpSync(sourcePath, targetPath, { - recursive: true, - force: false, - errorOnExist: true, - dereference: true - }) - markLaunchEntry(launchHomePath, entryName, sourcePath, 'copy') - } catch { - console.warn('[codex-home] Failed to copy shared Codex launch entry:', entryName, error) - } - } -} - -function materializeMissingSharedEntryIfNeeded(sourcePath: string, entryName: string): void { - if ( - process.platform !== 'win32' || - !isCodexSqliteSidecarEntryName(entryName) || - existsSync(sourcePath) - ) { - return - } - try { - writeFileSync(sourcePath, '', { flag: 'wx', mode: 0o600 }) - } catch (error) { - if (existsSync(sourcePath)) { - return - } - throw error - } -} - -function createSharedEntryLink(sourcePath: string, targetPath: string): void { - if (createWslSymlinkIfPossible(sourcePath, targetPath)) { - return - } - const sourceStat = existsSync(sourcePath) ? lstatSync(sourcePath) : null - if ( - sourceStat?.isFile() && - process.platform === 'win32' && - createHardLinkIfPossible(sourcePath, targetPath) - ) { - return - } - if (targetAlreadyPointsToSource(targetPath, sourcePath)) { - return - } - try { - symlinkSync( - sourcePath, - targetPath, - sourceStat?.isDirectory() && process.platform === 'win32' ? 'junction' : undefined - ) - } catch (error) { - if (targetAlreadyPointsToSource(targetPath, sourcePath)) { - return - } - if ( - sourceStat?.isFile() && - process.platform === 'win32' && - createHardLinkIfPossible(sourcePath, targetPath) - ) { - return - } - if (targetAlreadyPointsToSource(targetPath, sourcePath)) { - return - } - throw error - } -} - -function createHardLinkIfPossible(sourcePath: string, targetPath: string): boolean { - try { - linkSync(sourcePath, targetPath) - return true - } catch { - return false - } -} - -function createWslSymlinkIfPossible(sourcePath: string, targetPath: string): boolean { - const paths = getSameDistroWslPaths(sourcePath, targetPath) - if (!paths) { - return false - } - execFileSync( - 'wsl.exe', - ['-d', paths.distro, '--', 'ln', '-s', paths.sourceLinuxPath, paths.targetLinuxPath], - { - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 5000 - } - ) - return true -} - -function getSameDistroWslPaths( - sourcePath: string, - targetPath: string -): { distro: string; sourceLinuxPath: string; targetLinuxPath: string } | null { - if (process.platform !== 'win32') { - return null - } - const sourceWsl = parseWslUncPath(sourcePath) - const targetWsl = parseWslUncPath(targetPath) - if (!sourceWsl || !targetWsl || sourceWsl.distro !== targetWsl.distro) { - return null - } - return { - distro: sourceWsl.distro, - sourceLinuxPath: sourceWsl.linuxPath, - targetLinuxPath: targetWsl.linuxPath - } -} - -function runWslPathCommand(distro: string, args: string[]): boolean { - try { - execFileSync('wsl.exe', ['-d', distro, '--', ...args], { - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 5000 - }) - return true - } catch { - return false - } -} - -function wslPathExists(targetPath: string): boolean | null { - if (process.platform !== 'win32') { - return null - } - const targetWsl = parseWslUncPath(targetPath) - if (!targetWsl) { - return null - } - return runWslPathCommand(targetWsl.distro, [ - 'sh', - '-c', - 'test -e "$1" || test -L "$1"', - 'sh', - targetWsl.linuxPath - ]) -} - -function removeWslPathIfPossible(targetPath: string): boolean { - if (process.platform !== 'win32') { - return false - } - const targetWsl = parseWslUncPath(targetPath) - if (!targetWsl) { - return false - } - return runWslPathCommand(targetWsl.distro, ['rm', '-rf', '--', targetWsl.linuxPath]) -} - -function activeHomeAlreadyPointsToLaunchHome( - activeHomePath: string, - launchHomePath: string -): boolean { - if (targetAlreadyPointsToSource(activeHomePath, launchHomePath)) { - return true - } - return false -} - -function replaceActiveHomeLink(activeHomePath: string, nextLinkPath: string): void { - try { - renameSync(nextLinkPath, activeHomePath) - } catch (error) { - if (!activeHomeLinkIsReplaceable(activeHomePath)) { - throw error - } - removeActiveHomeLinkIfOwned(activeHomePath) - renameSync(nextLinkPath, activeHomePath) - } -} - -function activeHomeLinkIsReplaceable(activeHomePath: string): boolean { - try { - const stat = lstatSync(activeHomePath) - return stat.isSymbolicLink() || isWindowsReadableLink(activeHomePath) - } catch { - return true - } -} - -function removeActiveHomeLinkIfOwned(activeHomePath: string): void { - try { - const stat = lstatSync(activeHomePath) - if (stat.isSymbolicLink()) { - unlinkSync(activeHomePath) - } else if (isWindowsReadableLink(activeHomePath)) { - rmdirSync(activeHomePath) - } - } catch { - // Missing or inaccessible temporary links are handled by the caller. - } -} - -function isWindowsReadableLink(targetPath: string): boolean { - if (process.platform !== 'win32') { - return false - } - try { - readlinkSync(targetPath) - return true - } catch { - return false - } -} - -function readWslSymlinkTarget(targetPath: string): string | null { - if (process.platform !== 'win32') { - return null - } - const targetWsl = parseWslUncPath(targetPath) - if (!targetWsl) { - return null - } - try { - return execFileSync( - 'wsl.exe', - ['-d', targetWsl.distro, '--', 'readlink', targetWsl.linuxPath], - { - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 5000 - } - ).trim() - } catch { - return null - } -} - -function copyFallbackAllowed(sourcePath: string, entryName: string): boolean { - if (entryName === 'hooks.json') { - return false - } - if (isCodexSqliteEntryName(entryName)) { - // Why: copying SQLite/WAL/SHM files forks Codex state and breaks lock coherence. - return false - } - const sourceStat = lstatSync(sourcePath) - return !sourceStat.isDirectory() || !MUTABLE_SHARED_DIRECTORY_ENTRIES.has(entryName) -} - -function canLinkMissingSharedEntry(sharedHomePath: string, entryName: string): boolean { - const mainEntryName = getCodexSqliteMainEntryName(entryName) - return ( - isCodexSqliteSidecarEntryName(entryName) && - mainEntryName !== null && - existsSync(join(sharedHomePath, mainEntryName)) - ) -} - -function replaceUnownedLaunchEntryAllowed(launchHomePath: string, entryName: string): boolean { - // Why: older launch-home builds let Codex create local DB forks before the - // shared SQLite link policy existed; those forks must be replaced in place. - return isCodexSqliteEntryName(entryName) && existsSync(join(launchHomePath, LAUNCH_HOME_MARKER)) -} - -function isContainedPath(rootPath: string, candidatePath: string): boolean { - const relativePath = relative(resolve(rootPath), resolve(candidatePath)) - return ( - Boolean(relativePath) && - relativePath !== '..' && - !isAbsolute(relativePath) && - !relativePath.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) - ) -} - -function reconcileMutableLaunchHomeFilesIntoSharedHome( - sharedHomePath: string, - launchRootPath: string -): void { - let selectionEntries: string[] - try { - selectionEntries = readdirSync(launchRootPath) - } catch { - return - } - for (const selectionEntry of selectionEntries.sort()) { - const launchHomePath = join(launchRootPath, selectionEntry, 'home') - if (!existsSync(join(launchHomePath, LAUNCH_HOME_MARKER))) { - continue - } - reconcileMarkedMutableFiles(sharedHomePath, launchHomePath) - } -} - -function reconcileMarkedMutableFiles(sharedHomePath: string, launchHomePath: string): void { - const markerDir = join(launchHomePath, LAUNCH_HOME_LINK_MARKERS_DIR) - let markerFiles: string[] - try { - markerFiles = readdirSync(markerDir) - } catch { - return - } - for (const markerFile of markerFiles.sort()) { - const entryName = markerFile.replace(/\.json$/, '') - const marker = readLaunchEntryMarker(launchHomePath, entryName) - if (!marker || !MUTABLE_SHARED_FILE_ENTRIES.has(entryName)) { - continue - } - if (marker.sourcePath !== join(sharedHomePath, entryName)) { - continue - } - reconcileMutableLaunchEntryIfNeeded(marker.sourcePath, join(launchHomePath, entryName), marker) - } -} - -function reconcileMutableLaunchEntryIfNeeded( - sourcePath: string, - targetPath: string, - marker: LaunchEntryMarker | null -): void { - if (!marker || !MUTABLE_SHARED_FILE_ENTRIES.has(targetPath.split(/[\\/]/).at(-1) ?? '')) { - return - } - if (!targetExistsForLaunchRemoval(targetPath)) { - return - } - if (targetAlreadyPointsToSource(targetPath, sourcePath)) { - return - } - try { - if (lstatSync(targetPath).isSymbolicLink() || !statSync(targetPath).isFile()) { - return - } - const targetDigest = digestFile(targetPath) - if (targetDigest === marker.targetDigest) { - return - } - const sourceDigest = existsSync(sourcePath) ? digestFile(sourcePath) : null - if ( - sourceDigest !== null && - marker.sourceDigest !== null && - sourceDigest !== marker.sourceDigest && - statSync(sourcePath).mtimeMs > statSync(targetPath).mtimeMs - ) { - return - } - mkdirSync(dirname(sourcePath), { recursive: true }) - cpSync(targetPath, sourcePath, { force: true }) - } catch (error) { - console.warn('[codex-home] Failed to reconcile launch-home Codex entry:', targetPath, error) - } -} - -function removeStaleLaunchHomeEntries( - launchHomePath: string, - sharedHomePath: string, - sharedEntries: Set -): void { - const markerDir = join(launchHomePath, LAUNCH_HOME_LINK_MARKERS_DIR) - let markerFiles: string[] - try { - markerFiles = readdirSync(markerDir) - } catch { - return - } - for (const markerFile of markerFiles) { - const entryName = markerFile.replace(/\.json$/, '') - if (!sharedEntries.has(entryName)) { - removeLaunchEntryIfOwned( - join(launchHomePath, entryName), - launchHomePath, - entryName, - join(sharedHomePath, entryName) - ) - } - } -} - -function removeLaunchEntryIfOwned( - targetPath: string, - launchHomePath: string, - entryName: string, - sourcePath: string -): void { - const marker = readLaunchEntryMarker(launchHomePath, entryName) - if (marker?.sourcePath !== sourcePath) { - return - } - removeLaunchEntry(targetPath) - rmSync(getLaunchEntryMarkerPath(launchHomePath, entryName), { force: true }) -} - -function removeLaunchEntry(targetPath: string): void { - if (!targetExistsForLaunchRemoval(targetPath)) { - return - } - try { - if (removeWslPathIfPossible(targetPath)) { - return - } - const stat = lstatSync(targetPath) - if (stat.isSymbolicLink()) { - try { - unlinkSync(targetPath) - } catch (error) { - if (process.platform !== 'win32') { - throw error - } - rmdirSync(targetPath) - } - return - } - rmSync(targetPath, { recursive: stat.isDirectory(), force: true }) - } catch (error) { - console.warn('[codex-home] Failed to remove owned launch-home entry:', targetPath, error) - } -} - -function targetExistsForLaunchRemoval(targetPath: string): boolean { - const wslExists = wslPathExists(targetPath) - if (wslExists !== null) { - return wslExists - } - try { - lstatSync(targetPath) - return true - } catch { - return false - } -} - -function writeLaunchHomeMarker(launchHomePath: string, accountId: string | null): void { - writeFileSync( - join(launchHomePath, LAUNCH_HOME_MARKER), - `${JSON.stringify({ version: LAUNCH_HOME_MARKER_VERSION, accountId }, null, 2)}\n`, - { encoding: 'utf-8', mode: 0o600 } - ) -} - -function isMarkedLaunchHomeForAccount(launchHomePath: string, accountId: string): boolean { - try { - const parsed: unknown = JSON.parse( - readFileSync(join(launchHomePath, LAUNCH_HOME_MARKER), 'utf-8') - ) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return false - } - const marker = parsed as { version?: unknown; accountId?: unknown } - return marker.version === LAUNCH_HOME_MARKER_VERSION && marker.accountId === accountId - } catch { - return false - } -} - -function markLaunchEntry( - launchHomePath: string, - entryName: string, - sourcePath: string, - mode: 'link' | 'copy' -): void { - const markerPath = getLaunchEntryMarkerPath(launchHomePath, entryName) - mkdirSync(dirname(markerPath), { recursive: true }) - const shouldTrackDigests = MUTABLE_SHARED_FILE_ENTRIES.has(entryName) - writeFileSync( - markerPath, - `${JSON.stringify( - { - version: LAUNCH_HOME_MARKER_VERSION, - sourcePath, - mode, - // Why: digests are only used to reconcile mutable config-style files; - // hashing SQLite databases on every PTY launch can read gigabytes. - sourceDigest: shouldTrackDigests ? digestPathIfFile(sourcePath) : null, - targetDigest: shouldTrackDigests ? digestPathIfFile(join(launchHomePath, entryName)) : null - } satisfies LaunchEntryMarker, - null, - 2 - )}\n`, - { encoding: 'utf-8', mode: 0o600 } - ) -} - -function readLaunchEntryMarker( - launchHomePath: string, - entryName: string -): LaunchEntryMarker | null { - try { - const parsed: unknown = JSON.parse( - readFileSync(getLaunchEntryMarkerPath(launchHomePath, entryName), 'utf-8') - ) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return null - } - const marker = parsed as Partial - if ( - marker.version !== LAUNCH_HOME_MARKER_VERSION || - typeof marker.sourcePath !== 'string' || - (marker.mode !== 'link' && marker.mode !== 'copy') - ) { - return null - } - return { - version: marker.version, - sourcePath: marker.sourcePath, - mode: marker.mode, - sourceDigest: typeof marker.sourceDigest === 'string' ? marker.sourceDigest : null, - targetDigest: typeof marker.targetDigest === 'string' ? marker.targetDigest : null - } - } catch { - return null - } -} - -function targetAlreadyPointsToSource(targetPath: string, sourcePath: string): boolean { - const paths = getSameDistroWslPaths(sourcePath, targetPath) - if (paths) { - return readWslSymlinkTarget(targetPath) === paths.sourceLinuxPath - } - try { - const targetStat = lstatSync(targetPath) - if (targetStat.isSymbolicLink()) { - return linkTargetsMatch(readlinkSync(targetPath), sourcePath) - } - if (process.platform === 'win32') { - try { - if (linkTargetsMatch(readlinkSync(targetPath), sourcePath)) { - return true - } - } catch { - // Non-link files fall through to the hard-link identity check below. - } - } - if (!targetStat.isFile() || !existsSync(sourcePath)) { - return false - } - const sourceStat = statSync(sourcePath) - return ( - sourceStat.isFile() && - targetStat.dev === sourceStat.dev && - targetStat.ino === sourceStat.ino && - targetStat.nlink > 1 - ) - } catch { - return false - } -} - -function linkTargetsMatch(actualTarget: string, expectedTarget: string): boolean { - const expectedWsl = parseWslUncPath(expectedTarget) - if (expectedWsl && actualTarget === expectedWsl.linuxPath) { - return true - } - if (process.platform !== 'win32') { - return actualTarget === expectedTarget - } - return normalizeWindowsLinkTarget(actualTarget) === normalizeWindowsLinkTarget(expectedTarget) -} - -function normalizeWindowsLinkTarget(linkTarget: string): string { - return linkTarget.replace(/^\\\\\?\\/, '').toLowerCase() -} - -function getLaunchEntryMarkerPath(launchHomePath: string, entryName: string): string { - return join(launchHomePath, LAUNCH_HOME_LINK_MARKERS_DIR, `${entryName}.json`) -} - -function digestPathIfFile(targetPath: string): string | null { - try { - if (!statSync(targetPath).isFile()) { - return null - } - return digestFile(targetPath) - } catch { - return null - } -} - -function digestFile(targetPath: string): string { - return createHash('sha256').update(readFileSync(targetPath)).digest('hex') -} diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index 335119b16..fc96ff899 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -58,11 +58,6 @@ export type CodexHookTrustState = { enabled?: boolean } -export type CodexHookTrustBlock = { - key: string - trustedHash: string -} - export type CodexProjectTrustLevel = 'trusted' | 'untrusted' // Why: matches Codex's canonical_json. Sorts object keys recursively before @@ -111,10 +106,6 @@ export function computeTrustKey(entry: CodexTrustEntry): string { return `${getCodexCanonicalTrustPath(entry.sourcePath)}:${entry.eventLabel}:${entry.groupIndex}:${entry.handlerIndex}` } -export function computeTrustKeyWithSourcePath(entry: CodexTrustEntry, sourcePath: string): string { - return `${sourcePath}:${entry.eventLabel}:${entry.groupIndex}:${entry.handlerIndex}` -} - export function getCodexCanonicalTrustPath(sourcePath: string): string { try { // Why: Codex canonicalizes trust paths before building config keys. On @@ -221,18 +212,6 @@ export function upsertHookTrustEntries( writeConfigAtomically(configPath, updated) } -export function upsertHookTrustBlocks( - configPath: string, - blocks: readonly CodexHookTrustBlock[] -): void { - const existing = existsSync(configPath) ? readTomlFile(configPath) : '' - const updated = upsertHookTrustBlocksInContent(existing, blocks) - if (updated === existing) { - return - } - writeConfigAtomically(configPath, updated) -} - export function upsertHookTrustEntriesInContent( existingContent: string, entries: readonly CodexTrustEntry[] @@ -246,19 +225,6 @@ export function upsertHookTrustEntriesInContent( return updated } -export function upsertHookTrustBlocksInContent( - existingContent: string, - blocks: readonly CodexHookTrustBlock[] -): string { - const existing = - existingContent.charCodeAt(0) === 0xfeff ? existingContent.slice(1) : existingContent - let updated = existing - for (const block of blocks) { - updated = upsertTrustBlock(updated, block.key, block.trustedHash) - } - return updated -} - export function upsertProjectTrustLevel( configPath: string, projectPath: string, diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index 27941a52f..838fc034c 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -35,7 +35,7 @@ vi.mock('os', async (importOriginal) => { } }) -import { CodexHookService, trustCodexLaunchHomeHooks } from './hook-service' +import { CodexHookService } from './hook-service' let tmpHome: string let userDataDir: string @@ -74,10 +74,6 @@ function hookTrustHeader(key: string): string { return `[hooks.state."${escapeTomlBasicString(canonicalizeHookTrustKeyForTest(key))}"]` } -function literalHookTrustHeader(key: string): string { - return `[hooks.state."${escapeTomlBasicString(key)}"]` -} - function canonicalizeHookTrustKeyForTest(key: string): string { const lastColon = key.lastIndexOf(':') const secondLast = lastColon === -1 ? -1 : key.lastIndexOf(':', lastColon - 1) @@ -762,7 +758,7 @@ describe('CodexHookService', () => { hooks: Record } expect(systemHooks.hooks.Stop).toBeUndefined() - }) + }, 15_000) it('removes the legacy Orca Codex profile file when it only contains managed hooks', () => { const systemCodexHome = join(tmpHome, '.codex') @@ -999,7 +995,7 @@ describe('CodexHookService', () => { expect(runtimeToml).not.toContain(':stop:0:0') }) - it('preserves runtime Codex prefs and hook trust on hook install without a sync baseline', () => { + it('mirrors system Codex config while preserving runtime hook trust on hook install', () => { const systemCodexHome = join(tmpHome, '.codex') mkdirSync(systemCodexHome, { recursive: true }) writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8') @@ -1023,66 +1019,12 @@ describe('CodexHookService', () => { expect(status.state).toBe('installed') const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') - expect(trustConfig).toContain('model = "runtime-model"') + expect(trustConfig).toContain('model = "system-model"') expect(trustConfig).toContain('[hooks.state."runtime-hook"]') expect(trustConfig).toContain('enabled = false') expect(trustConfig).toContain('trusted_hash = "sha256:runtime"') expect(trustConfig).toContain(':permission_request:0:0') - expect(trustConfig).not.toContain('model = "system-model"') - }) - - it('mirrors runtime hook trust to a materialized launch-home hooks path', () => { - const service = new CodexHookService() - expect(service.install().state).toBe('installed') - - const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') - const managedHooksPath = join(managedCodexHome, 'hooks.json') - const launchHome = join(userDataDir, 'codex-runtime-home', 'launch', 'host', 'system', 'home') - mkdirSync(launchHome, { recursive: true }) - if (process.platform === 'win32') { - writeFileSync(join(launchHome, 'hooks.json'), readFileSync(managedHooksPath, 'utf-8')) - writeFileSync( - join(launchHome, 'config.toml'), - readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') - ) - } else { - symlinkSync(managedHooksPath, join(launchHome, 'hooks.json')) - symlinkSync(join(managedCodexHome, 'config.toml'), join(launchHome, 'config.toml')) - } - - trustCodexLaunchHomeHooks(launchHome) - - const trustConfig = readFileSync(join(launchHome, 'config.toml'), 'utf-8') - const launchHooksTrustPath = join(realpathSync.native(launchHome), 'hooks.json') - expect(trustConfig).toContain( - literalHookTrustHeader(`${launchHooksTrustPath}:session_start:0:0`) - ) - expect(trustConfig).toContain(hookTrustHeader(`${managedHooksPath}:session_start:0:0`)) - }) - - it('mirrors runtime hook trust when launch-home config is stale', () => { - const service = new CodexHookService() - expect(service.install().state).toBe('installed') - - const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') - const managedHooksPath = join(managedCodexHome, 'hooks.json') - const launchHome = join(userDataDir, 'codex-runtime-home', 'launch', 'host', 'system', 'home') - mkdirSync(launchHome, { recursive: true }) - if (process.platform === 'win32') { - writeFileSync(join(launchHome, 'hooks.json'), readFileSync(managedHooksPath, 'utf-8')) - } else { - symlinkSync(managedHooksPath, join(launchHome, 'hooks.json')) - } - writeFileSync(join(launchHome, 'config.toml'), 'model = "stale-launch-config"\n', 'utf-8') - - trustCodexLaunchHomeHooks(launchHome) - - const trustConfig = readFileSync(join(launchHome, 'config.toml'), 'utf-8') - const launchHooksTrustPath = join(realpathSync.native(launchHome), 'hooks.json') - expect(trustConfig).toContain('model = "stale-launch-config"') - expect(trustConfig).toContain( - literalHookTrustHeader(`${launchHooksTrustPath}:session_start:0:0`) - ) + expect(trustConfig).not.toContain('model = "runtime-model"') }) it('repairs duplicate managed SessionStart trust tables on restart install', () => { @@ -1170,9 +1112,9 @@ describe('CodexHookService', () => { expect(status.state).toBe('installed') const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') - expect(trustConfig).toContain('model = "runtime-model"') + expect(trustConfig).toContain('model = "system-model"') expect(trustConfig).toContain('[projects."/repo"]\ntrust_level = "untrusted"') expect(trustConfig).toContain('[projects."/runtime-only"]\ntrust_level = "trusted"') - expect(trustConfig).not.toContain('model = "system-model"') + expect(trustConfig).not.toContain('model = "runtime-model"') }) }) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 99e5af8fc..aa9b6f7b0 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: getStatus + install + remove all share the managed-command and trust-key derivation. Splitting would hide that the three operations must agree on group index, event label, and command bytes. */ -import { existsSync, readFileSync, realpathSync, unlinkSync } from 'fs' +import { existsSync, readFileSync, unlinkSync } from 'fs' import { join } from 'path' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' @@ -25,14 +25,12 @@ import { } from '../agent-hooks/installer-utils-remote' import { computeTrustKey, - computeTrustKeyWithSourcePath, computeTrustedHash, escapeTomlString, getCodexCanonicalTrustPath, parseTrustKey, readHookTrustEntries, removeHookTrustEntries, - upsertHookTrustBlocks, upsertHookTrustEntriesInContent, upsertHookTrustEntries, writeConfigAtomically, @@ -65,10 +63,6 @@ function getCodexConfigTomlPath(): string { return join(getOrcaManagedCodexHomePath(), 'config.toml') } -function getLaunchHomeCodexConfigTomlPath(launchHomePath: string): string { - return join(launchHomePath, 'config.toml') -} - // Why: Codex's hash key uses the snake_case event label (see // codex-rs/hooks/src/lib.rs::hook_event_key_label). Our hooks.json uses the // PascalCase serde-rename. Map between them at one place so the trust-write @@ -477,101 +471,6 @@ function applyMirroredRuntimeUserHookTrustStates( } } -function applyHookTrustStatesByKey( - tomlPath: string, - entries: readonly { key: string; enabled: boolean }[] -): void { - if (entries.length === 0 || !existsSync(tomlPath)) { - return - } - - const existing = readFileSync(tomlPath, 'utf-8') - let updated = existing - for (const { key, enabled } of entries) { - const escapedKey = escapeRegex(escapeTomlString(key)) - const pattern = new RegExp( - `(\\[hooks\\.state\\."${escapedKey}"\\]\\r?\\n(?:[ \\t]*enabled[ \\t]*=[ \\t]*)(true|false))` - ) - if (pattern.test(updated)) { - updated = updated.replace(pattern, (_match, prefix: string) => { - return `${prefix.slice(0, prefix.lastIndexOf('=') + 1)} ${enabled}` - }) - continue - } - const headerPattern = new RegExp(`(\\[hooks\\.state\\."${escapedKey}"\\]\\r?\\n)`) - updated = updated.replace(headerPattern, `$1enabled = ${enabled}\n`) - } - if (updated !== existing) { - writeConfigAtomically(tomlPath, updated) - } -} - -function getLaunchHomeHookTrustSourcePath(launchHomePath: string): string { - try { - // Why: Codex 0.135 canonicalizes CODEX_HOME, then keys hooks by the - // CODEX_HOME-relative hooks.json path without resolving that file symlink. - return join(realpathSync.native(launchHomePath), 'hooks.json') - } catch { - return join(launchHomePath, 'hooks.json') - } -} - -export function trustCodexLaunchHomeHooks(launchHomePath: string): void { - const runtimeConfigPath = getConfigPath() - const runtimeTomlPath = getCodexConfigTomlPath() - const launchHooksPath = join(launchHomePath, 'hooks.json') - const launchTrustSourcePath = getLaunchHomeHookTrustSourcePath(launchHomePath) - const tomlPath = getLaunchHomeCodexConfigTomlPath(launchHomePath) - const config = readHooksJson(launchHooksPath) - if (!config?.hooks) { - return - } - - // Why: account launch homes isolate auth.json, but hook trust is shared - // runtime config. A launch config.toml can be a stale mutable copy, while - // Codex keys trust by the selected launch home's hooks.json path. - const trustEntries = readHookTrustEntries(runtimeTomlPath) - const launchTrustBlocks: { key: string; trustedHash: string }[] = [] - const launchTrustStates: { key: string; enabled: boolean }[] = [] - for (const [eventName, definitions] of Object.entries(config.hooks)) { - if (!Array.isArray(definitions)) { - continue - } - definitions.forEach((definition, groupIndex) => { - const hooks = Array.isArray(definition.hooks) ? definition.hooks : [] - hooks.forEach((hook, handlerIndex) => { - const runtimeEntry = createHookTrustEntry( - runtimeConfigPath, - eventName, - groupIndex, - handlerIndex, - definition, - hook - ) - if (!runtimeEntry) { - return - } - const runtimeState = trustEntries.get(computeTrustKey(runtimeEntry)) - const trustedHash = computeTrustedHash(runtimeEntry) - if (runtimeState?.trustedHash !== trustedHash) { - return - } - const launchKey = computeTrustKeyWithSourcePath(runtimeEntry, launchTrustSourcePath) - launchTrustBlocks.push({ key: launchKey, trustedHash }) - if (runtimeState.enabled !== undefined) { - launchTrustStates.push({ key: launchKey, enabled: runtimeState.enabled }) - } - }) - }) - } - - if (launchTrustBlocks.length === 0) { - return - } - upsertHookTrustBlocks(tomlPath, launchTrustBlocks) - applyHookTrustStatesByKey(tomlPath, launchTrustStates) -} - function dedupeHookDefinitions(definitions: readonly HookDefinition[]): HookDefinition[] { const seen = new Set() return definitions.filter((definition) => { diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index f4cc31390..a5a9d8127 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -36,7 +36,6 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => { import { createPtySubprocess } from './pty-subprocess' const ORCA_SHELL_WRAPPER_ENV = [ - 'CODEX_HOME', 'ORCA_ATTRIBUTION_SHIM_DIR', 'ORCA_OPENCODE_CONFIG_DIR', 'ORCA_PI_CODING_AGENT_DIR', @@ -1189,8 +1188,12 @@ describe('createPtySubprocess', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const savedCodexHome = process.env.CODEX_HOME + const savedOrcaCodexHome = process.env.ORCA_CODEX_HOME Object.defineProperty(process, 'platform', { value: 'win32' }) + delete process.env.CODEX_HOME + delete process.env.ORCA_CODEX_HOME try { createPtySubprocess({ @@ -1207,6 +1210,16 @@ describe('createPtySubprocess', () => { if (platform) { Object.defineProperty(process, 'platform', platform) } + if (savedCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = savedCodexHome + } + if (savedOrcaCodexHome === undefined) { + delete process.env.ORCA_CODEX_HOME + } else { + process.env.ORCA_CODEX_HOME = savedOrcaCodexHome + } } expect(spawnMock).toHaveBeenCalledWith( diff --git a/src/main/index.ts b/src/main/index.ts index 616097bb9..b64afed87 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -404,9 +404,6 @@ function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): error ) } - if (target?.runtime !== 'wsl') { - return codexRuntimeHome!.refreshCurrentHostActiveHome() ?? runtimeHomePath - } return runtimeHomePath } diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 840dc1e1c..4da15a14b 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -175,8 +175,6 @@ function makeDisposable() { } describe('registerPtyHandlers', () => { - const testCodexHomePath = - process.platform === 'win32' ? 'C:\\tmp\\orca-codex-home' : '/tmp/orca-codex-home' const handlers = new Map unknown>() const mainWindow = { isDestroyed: () => false, @@ -531,9 +529,9 @@ describe('registerPtyHandlers', () => { }) it('injects the selected Codex home into Orca terminal PTYs', async () => { - const env = await spawnAndGetEnv(undefined, undefined, () => testCodexHomePath) - expect(env.CODEX_HOME).toBe(testCodexHomePath) - expect(env.ORCA_CODEX_HOME).toBe(testCodexHomePath) + const env = await spawnAndGetEnv(undefined, undefined, () => '/tmp/orca-codex-home') + expect(env.CODEX_HOME).toBe('/tmp/orca-codex-home') + expect(env.ORCA_CODEX_HOME).toBe('/tmp/orca-codex-home') }) it('injects the OpenCode hook env into Orca terminal PTYs', async () => { @@ -868,10 +866,10 @@ describe('registerPtyHandlers', () => { const env = await spawnAndGetEnv( undefined, { CODEX_HOME: '/tmp/system-codex-home' }, - () => testCodexHomePath + () => '/tmp/orca-codex-home' ) - expect(env.CODEX_HOME).toBe(testCodexHomePath) - expect(env.ORCA_CODEX_HOME).toBe(testCodexHomePath) + expect(env.CODEX_HOME).toBe('/tmp/orca-codex-home') + expect(env.ORCA_CODEX_HOME).toBe('/tmp/orca-codex-home') }) it('injects explicit proxy settings into local PTY env', async () => { @@ -1077,9 +1075,9 @@ describe('registerPtyHandlers', () => { }) it('injects the selected Codex home on the daemon path', async () => { - const env = await daemonSpawnAndGetEnv({}, () => testCodexHomePath) - expect(env.CODEX_HOME).toBe(testCodexHomePath) - expect(env.ORCA_CODEX_HOME).toBe(testCodexHomePath) + const env = await daemonSpawnAndGetEnv({}, () => '/tmp/orca-codex-home') + expect(env.CODEX_HOME).toBe('/tmp/orca-codex-home') + expect(env.ORCA_CODEX_HOME).toBe('/tmp/orca-codex-home') }) it('injects explicit proxy settings on the daemon path', async () => { @@ -2709,50 +2707,6 @@ describe('registerPtyHandlers', () => { expect(spawnOptions.env.CODEX_HOME).toBeUndefined() expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined() }) - - it('converts selected WSL active Codex homes to Linux paths for wsl.exe shells', () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'win32' - }) - process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe' - isPwshAvailableMock.mockReturnValue(false) - - try { - registerPtyHandlers( - mainWindow as never, - undefined, - () => - '\\\\wsl.localhost\\Ubuntu\\home\\test\\.local\\share\\orca\\codex-runtime-home\\active\\wsl\\home', - () => - ({ - terminalWindowsShell: 'powershell.exe', - terminalWindowsPowerShellImplementation: 'powershell.exe' - }) as never - ) - handlers.get('pty:spawn')!(null, { - cols: 80, - rows: 24, - shellOverride: 'wsl.exe' - }) - - const spawnOptions = spawnMock.mock.calls.at(-1)?.[2] as { env: Record } - expect(spawnOptions.env.CODEX_HOME).toBe( - '/home/test/.local/share/orca/codex-runtime-home/active/wsl/home' - ) - expect(spawnOptions.env.ORCA_CODEX_HOME).toBe( - '/home/test/.local/share/orca/codex-runtime-home/active/wsl/home' - ) - expect(spawnOptions.env.WSLENV).toContain('CODEX_HOME') - expect(spawnOptions.env.WSLENV).toContain('ORCA_CODEX_HOME') - } finally { - Object.defineProperty(process, 'platform', { - configurable: true, - value: originalPlatform - }) - } - }) }) it('rejects missing WSL worktree cwd instead of validating only the fallback Windows cwd', async () => { diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 64f5ca031..66cba7cc9 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -69,19 +69,13 @@ describe('LocalPtyProvider', () => { } let exitCb: ((info: { exitCode: number }) => void) | undefined let origShell: string | undefined - let origCodexHome: string | undefined - let origOrcaCodexHome: string | undefined let origPlatform: PropertyDescriptor | undefined beforeEach(() => { origPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) origShell = process.env.SHELL - origCodexHome = process.env.CODEX_HOME - origOrcaCodexHome = process.env.ORCA_CODEX_HOME process.env.SHELL = '/bin/zsh' - delete process.env.CODEX_HOME - delete process.env.ORCA_CODEX_HOME existsSyncMock.mockReturnValue(true) statSyncMock.mockReturnValue({ isDirectory: () => true, mode: 0o755 }) @@ -117,16 +111,6 @@ describe('LocalPtyProvider', () => { } else { process.env.SHELL = origShell } - if (origCodexHome === undefined) { - delete process.env.CODEX_HOME - } else { - process.env.CODEX_HOME = origCodexHome - } - if (origOrcaCodexHome === undefined) { - delete process.env.ORCA_CODEX_HOME - } else { - process.env.ORCA_CODEX_HOME = origOrcaCodexHome - } }) describe('spawn', () => { @@ -318,6 +302,10 @@ describe('LocalPtyProvider', () => { it('marks Orca terminal handle for WSL import when buildSpawnEnv opts in', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const savedCodexHome = process.env.CODEX_HOME + const savedOrcaCodexHome = process.env.ORCA_CODEX_HOME + delete process.env.CODEX_HOME + delete process.env.ORCA_CODEX_HOME provider.configure({ buildSpawnEnv: (_id, env, ctx) => { env.ORCA_TERMINAL_HANDLE = 'term_wsl' @@ -328,11 +316,24 @@ describe('LocalPtyProvider', () => { } }) - await provider.spawn({ - cols: 80, - rows: 24, - cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' - }) + try { + await provider.spawn({ + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' + }) + } finally { + if (savedCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = savedCodexHome + } + if (savedOrcaCodexHome === undefined) { + delete process.env.ORCA_CODEX_HOME + } else { + process.env.ORCA_CODEX_HOME = savedOrcaCodexHome + } + } const spawnCall = spawnMock.mock.calls.at(-1)! expect(spawnCall[0]).toBe('wsl.exe')