Preserve Codex runtime config preferences (#3944)

This commit is contained in:
Jinwoo Hong 2026-05-31 16:40:39 -04:00 committed by GitHub
parent 4b904a4e4d
commit ec01ffb986
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 2806 additions and 80 deletions

View File

@ -11,7 +11,9 @@
"../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",

View File

@ -0,0 +1,208 @@
# 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:105) prepares launch and rate-limit homes.
- [src/main/codex-accounts/runtime-home-service.ts](../src/main/codex-accounts/runtime-home-service.ts:146) materializes the active account by writing `auth.json` into the shared runtime home.
- [src/main/ipc/pty.ts](../src/main/ipc/pty.ts:608) injects the selected `CODEX_HOME` into new 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.
- Account switching cannot mutate the process environment of already-open PTYs. Existing host PTYs that were opened under the old account are stale until restarted, even if no Codex process is currently foregrounded.
## 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/<selection>/home`, where `<selection>` is `system` or `account-<sha256(account id)>`. 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. Do not launch new host Codex sessions from the shared environment home.
`prepareForCodexLaunch()` and rate-limit fetches return the selected launch home. 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. Keep WSL behavior stable in this change.
Existing WSL runtime homes are already per-distro and selected by target. This change does not solve same-distro WSL multi-account stale-auth races; that remains a residual risk. Tests should prove host Windows WSL path stripping still works and host launch homes do not leak into WSL.
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.
- 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.
- `CODEX_HOME` and `ORCA_CODEX_HOME` point to selected launch home.
- Old live Codex process:
- Continues writing whichever home it launched from.
- 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 `CODEX_HOME` environment it was spawned with.
- If the user later runs `codex` inside that shell, it can still use the old account.
- The UI must mark affected host terminal sessions stale or clearly limit the guarantee to fresh PTYs.
```text
native ~/.codex
user resources/config source only
Orca/codex-runtime-home/home
shared config.toml, hooks.json, sessions, resources
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 an old PTY with old `CODEX_HOME`.
- 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.
## 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.
- 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 a 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()` and `prepareForRateLimitFetch()` to 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-switch idle shell cannot have its process environment changed; it must be restarted or marked stale.
- Same-distro WSL launch homes remain out of scope for this host-focused change.
- 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.

View File

@ -1,5 +1,6 @@
/* 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,
@ -154,6 +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 normalizeLinkTarget(linkTarget: string): string {
return process.platform === 'win32'
? linkTarget.replace(/^\\\\\?\\/, '').toLowerCase()
@ -547,14 +552,254 @@ describe('CodexRuntimeHomeService', () => {
expect(existsSync(runtimeAuthPath)).toBe(false)
})
it('returns the Orca-managed runtime home for Codex launch and rate-limit preparation', async () => {
it('returns the selected launch 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(getRuntimeCodexHomePath())
expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath())
expect(service.prepareForCodexLaunch()).toBe(getSystemLaunchCodexHomePath())
expect(service.prepareForRateLimitFetch()).toBe(getSystemLaunchCodexHomePath())
expect(existsSync(getRuntimeCodexHomePath())).toBe(true)
expect(existsSync(getSystemLaunchCodexHomePath())).toBe(true)
})
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)
const launchHome1 = service.prepareForCodexLaunch()
settings.activeCodexManagedAccountId = 'account-2'
settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} }
const launchHome2 = service.prepareForCodexLaunch()
expect(launchHome1).not.toBe(launchHome2)
expect(launchHome1).toContain(join('codex-runtime-home', 'launch', 'host', 'account-'))
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('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 () => {
@ -574,6 +819,28 @@ 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 })
@ -733,14 +1000,14 @@ describe('CodexRuntimeHomeService', () => {
)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"host-system"}\n')
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
expect(service.prepareForCodexLaunch()).toBe(getSystemLaunchCodexHomePath())
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
wslRuntimeHomePath
)
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(
'{"account":"wsl"}\n'
)
expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath())
expect(service.prepareForRateLimitFetch()).toBe(getSystemLaunchCodexHomePath())
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
wslRuntimeHomePath
)
@ -1387,6 +1654,31 @@ 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')
@ -1957,13 +2249,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 shared runtime file after
// Orca selected account-2. Persist the refresh to account-1, then restore
// the selected account in runtime CODEX_HOME.
// 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.
writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8')
service.syncForCurrentSelection()
expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(account1RefreshedAuth)
expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(account1Auth)
expect(readFileSync(managedAuthPath2, 'utf-8')).toBe(account2Auth)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth)
})

View File

@ -23,6 +23,11 @@ import {
getSystemCodexHomePath,
syncSystemCodexResourcesIntoManagedHome
} from '../codex/codex-home-paths'
import {
ensureOrcaCodexLaunchHome,
materializeOrcaCodexLaunchHome,
removeOrcaCodexLaunchHome
} from '../codex/codex-launch-home-paths'
import { syncSystemCodexSessionsIntoManagedHome } from '../codex/codex-session-bridge'
import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror'
import { parseWslUncPath } from '../../shared/wsl-paths'
@ -76,6 +81,7 @@ 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<string, string | null>()
// 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.
@ -115,7 +121,7 @@ export class CodexRuntimeHomeService {
syncSystemCodexResourcesIntoManagedHome()
syncSystemConfigIntoManagedCodexHome()
syncSystemCodexSessionsIntoManagedHome()
return this.getRuntimeHomePath()
return this.materializeCurrentHostLaunchHome()
}
private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null {
@ -141,7 +147,16 @@ export class CodexRuntimeHomeService {
this.syncForCurrentSelection()
syncSystemCodexResourcesIntoManagedHome()
syncSystemConfigIntoManagedCodexHome()
return this.getRuntimeHomePath()
return this.materializeCurrentHostLaunchHome()
}
refreshCurrentHostLaunchHome(): string | null {
try {
return this.materializeCurrentHostLaunchHome()
} catch (error) {
console.warn('[codex-runtime-home] Failed to refresh host launch home:', error)
return null
}
}
syncForCurrentSelection(target?: CodexAccountSelectionTarget): void {
@ -177,6 +192,7 @@ export class CodexRuntimeHomeService {
}
this.lastSyncedAccountId = null
this.lastWrittenAuthJson = null
this.setLastWrittenHostAuthJson(null, null)
this.skipNextReadBackForAccountId = null
return
}
@ -289,13 +305,39 @@ 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))
}
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)
}
@ -363,8 +405,26 @@ 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
})
}
@ -828,6 +888,34 @@ export class CodexRuntimeHomeService {
return join(this.getRuntimeHomePath(), 'auth.json')
}
private getHostLaunchAuthPath(accountId: string | null): string {
return join(ensureOrcaCodexLaunchHome(accountId), 'auth.json')
}
private materializeCurrentHostLaunchHome(): string {
return materializeOrcaCodexLaunchHome(
normalizeCodexRuntimeSelection(this.store.getSettings()).host
)
}
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')
}
@ -1020,16 +1108,26 @@ export class CodexRuntimeHomeService {
private syncRuntimeAuthWithSystemDefault(): void {
const runtimeAuthPath = this.getRuntimeAuthPath()
const launchAuthPath = this.getHostLaunchAuthPath(null)
const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json')
if (!existsSync(runtimeAuthPath)) {
if (!existsSync(runtimeAuthPath) && !existsSync(launchAuthPath)) {
return
}
try {
const runtimeAuth = readFileSync(runtimeAuthPath, 'utf-8')
const launchAuth = existsSync(launchAuthPath) ? readFileSync(launchAuthPath, 'utf-8') : null
const sharedAuth = existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null
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
@ -1043,9 +1141,17 @@ 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 &&
@ -1056,7 +1162,9 @@ export class CodexRuntimeHomeService {
// sync does not overwrite fresh runtime credentials with stale ones.
this.writeSystemDefaultAuth(runtimeAuth)
this.captureSystemDefaultSnapshot({ force: true })
this.lastWrittenAuthJson = runtimeAuth
this.setLastWrittenHostAuthJson(null, runtimeAuth)
this.writeRuntimeAuthAtPath(runtimeAuthPath, runtimeAuth)
this.writeRuntimeAuthAtPath(launchAuthPath, runtimeAuth)
return
}
// Why: the unmanaged path used to read ~/.codex directly. Mirror later
@ -1064,12 +1172,68 @@ 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()
@ -1086,7 +1250,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.lastWrittenAuthJson = null
this.clearHostRuntimeAuthBaseline()
return
}
@ -1094,10 +1258,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.
rmSync(runtimeAuthPath, { force: true })
this.removeHostRuntimeAuth(runtimeAuthPath)
this.captureSystemDefaultSnapshot({ force: true })
this.persistRuntimeLogoutMarker()
this.lastWrittenAuthJson = null
this.clearHostRuntimeAuthBaseline()
return
}
@ -1112,21 +1276,21 @@ export class CodexRuntimeHomeService {
this.captureSystemDefaultSnapshot({ force: true })
const refreshedSnapshot = this.readSystemDefaultSnapshot(snapshotPath)
if (!refreshedSnapshot) {
rmSync(runtimeAuthPath, { force: true })
this.lastWrittenAuthJson = null
this.removeHostRuntimeAuth(runtimeAuthPath)
this.clearHostRuntimeAuthBaseline()
return
}
if (refreshedSnapshot.authJson === null) {
rmSync(runtimeAuthPath, { force: true })
this.lastWrittenAuthJson = null
this.removeHostRuntimeAuth(runtimeAuthPath)
this.clearHostRuntimeAuthBaseline()
return
}
this.writeRuntimeAuth(refreshedSnapshot.authJson)
return
}
if (snapshot.authJson === null) {
rmSync(runtimeAuthPath, { force: true })
this.lastWrittenAuthJson = null
this.removeHostRuntimeAuth(runtimeAuthPath)
this.clearHostRuntimeAuthBaseline()
return
}
this.writeRuntimeAuth(snapshot.authJson)
@ -1143,10 +1307,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.
rmSync(runtimeAuthPath, { force: true })
this.removeHostRuntimeAuth(runtimeAuthPath)
this.captureSystemDefaultSnapshot({ force: true })
this.persistRuntimeLogoutMarker()
this.lastWrittenAuthJson = null
this.clearHostRuntimeAuthBaseline()
}
private readSystemDefaultAuth(): string | null {
@ -1158,13 +1322,18 @@ 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()
if (this.fileContentsEqual(this.getRuntimeAuthPath(), contents)) {
this.ensureOwnerOnlyMode(this.getRuntimeAuthPath())
this.lastWrittenAuthJson = contents
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)
return
}
writeFileAtomically(this.getRuntimeAuthPath(), contents, { mode: 0o600 })
this.lastWrittenAuthJson = contents
writeFileAtomically(runtimeAuthPath, contents, { mode: 0o600 })
this.setLastWrittenHostAuthJson(accountId, contents)
this.writeRuntimeAuthAtPath(launchAuthPath, contents)
}
private writeRuntimeAuthAtPath(authPath: string, contents: string): void {
@ -1176,6 +1345,21 @@ 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

View File

@ -229,6 +229,7 @@ export class CodexAccountService {
})
this.runtimeHome.syncForCurrentSelection()
this.runtimeHome.removeHostLaunchHomeForAccount?.(accountId)
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.

View File

@ -1,5 +1,16 @@
/* 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, writeFileSync } from 'node:fs'
import {
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
utimesSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import type * as NodeOs from 'node:os'
import { join } from 'node:path'
@ -41,6 +52,15 @@ 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-'))
@ -93,6 +113,45 @@ 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(),
@ -123,7 +182,7 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
})
it('mirrors system config updates while preserving runtime-owned trust sections', () => {
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
establishSystemConfigBaseline('model = "initial-system-model"\n')
writeFileSync(
getRuntimeConfigPath(),
[
@ -175,8 +234,763 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
expect(runtimeConfig.match(/\[projects\."\/repo"\]/g)?.length).toBe(1)
})
it('does not treat TOML table headers inside multiline strings as sections', () => {
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<string, unknown>, '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')
writeFileSync(
getRuntimeConfigPath(),
[
@ -216,7 +1030,7 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
})
it('does not let triple quotes in comments affect runtime-owned section mirroring', () => {
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
establishSystemConfigBaseline('model = "initial-system-model"\n')
writeFileSync(
getRuntimeConfigPath(),
[

View File

@ -1,7 +1,18 @@
/* 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')
@ -31,18 +42,163 @@ 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 = mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig)
const mergedConfig = mergeChangedSystemConfigUnitsIntoRuntime(
runtimeConfig,
systemConfigUnits,
lastSyncedMirrorableSystemConfig.unitDigests
)
if (mergedConfig !== runtimeConfig) {
writeFileAtomically(runtimeConfigPath, mergedConfig)
}
writeLastSyncedMirrorableSystemCodexConfigDigest(mirrorableSystemConfig, systemConfigUnitDigests)
}
function normalizeDeprecatedCodexHookFeatureFlag(config: string): string {
@ -109,33 +265,390 @@ function normalizeFeatureSectionLines(lines: string[], start: number, end: numbe
}
}
function mergeSystemCodexConfigIntoRuntime(runtimeConfig: string, systemConfig: string): string {
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<string, number>()
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<string, string> {
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, string>
): 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<string>()
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<string, string>
): Record<string, string> {
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 {
const runtimeSections = getTomlSections(runtimeConfig)
const runtimeProjectHeaders = new Set(
runtimeSections
.filter((section) => isRuntimeProjectTomlSection(section.header))
.map((section) => getTomlSectionHeaderKey(section.header))
.map((section) => getTomlSectionIdentityKey(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 systemProjectSections = getTomlSections(systemConfig).filter((section) =>
isRuntimeProjectTomlSection(section.header)
)
// 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.
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.
return joinTomlBlocks([
stripRuntimeOwnedTomlSections(systemConfig, runtimeProjectHeaders),
...runtimeSections
.filter((section) => isRuntimePreservedTomlSection(section.header))
.filter(
(section) =>
!isRuntimeProjectTomlSection(section.header) ||
!systemUntrustedProjectHeaders.has(getTomlSectionHeaderKey(section.header))
)
.map((section) => section.block)
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)
])
}
@ -167,7 +680,7 @@ function stripRuntimeOwnedTomlSections(
.filter(
(section) =>
!isRuntimeProjectTomlSection(section.header) ||
!runtimeProjectHeaders.has(getTomlSectionHeaderKey(section.header)) ||
!runtimeProjectHeaders.has(getTomlSectionIdentityKey(section.header)) ||
getProjectTrustLevel(section.block) === 'untrusted'
)
.map((section) => section.block)
@ -212,20 +725,99 @@ function getTomlSections(config: string): TomlSection[] {
return sections
}
function isRuntimePreservedTomlSection(header: string): boolean {
return isRuntimeHookTrustTomlSection(header) || isRuntimeProjectTomlSection(header)
}
function isRuntimeHookTrustTomlSection(header: string): boolean {
return header.trimStart().startsWith('[hooks.state.')
const table = parseTomlTableHeaderPath(header)
return table?.parts[0] === 'hooks' && table.parts[1] === 'state' && table.parts.length > 2
}
function isRuntimeProjectTomlSection(header: string): boolean {
return header.trimStart().startsWith('[projects.')
const table = parseTomlTableHeaderPath(header)
return table?.parts[0] === 'projects' && table.parts.length > 1
}
function getTomlSectionHeaderKey(header: string): string {
return header.trim()
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 getProjectTrustLevel(block: string): 'trusted' | 'untrusted' | null {

View File

@ -0,0 +1,151 @@
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<string, string>
}
type CodexConfigSyncStateRead =
| {
status: 'valid'
digest: string
unitDigests: Record<string, string> | 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<string, string> {
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<string, string>
): void {
writeLastSyncedMirrorableSystemCodexConfigDigestValue(
getSystemCodexConfigDigest(mirrorableSystemConfig),
unitDigests
)
}
export function writeLastSyncedMirrorableSystemCodexConfigDigestValue(
digest: string,
unitDigests: Record<string, string>
): 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<string, string>
}): 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
})
}

View File

@ -0,0 +1,477 @@
/* 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,
mkdirSync,
readFileSync,
readlinkSync,
readdirSync,
rmdirSync,
rmSync,
statSync,
symlinkSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import { createHash } from 'node:crypto'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
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 {
const launchHomePath = resolveOrcaCodexLaunchHomePath(accountId, { create: true })
mkdirSync(launchHomePath, { recursive: true })
return launchHomePath
}
export function ensureOrcaCodexLaunchHome(accountId: string | null): string {
const launchHomePath = getOrcaCodexLaunchHomePath(accountId)
writeLaunchHomeMarker(launchHomePath, accountId)
return launchHomePath
}
export function materializeOrcaCodexLaunchHome(accountId: string | null): string {
reconcileMutableLaunchHomeFilesIntoSharedHome()
const sharedHomePath = getOrcaManagedCodexHomePath()
const launchHomePath = getOrcaCodexLaunchHomePath(accountId)
writeLaunchHomeMarker(launchHomePath, accountId)
const sharedEntries = new Set<string>()
for (const entryName of listSharedLaunchEntryNames(sharedHomePath)) {
sharedEntries.add(entryName)
linkSharedEntryIntoLaunchHome(sharedHomePath, launchHomePath, entryName)
}
removeStaleLaunchHomeEntries(launchHomePath, sharedHomePath, sharedEntries)
return launchHomePath
}
export function removeOrcaCodexLaunchHome(accountId: string): void {
const launchHomePath = resolveOrcaCodexLaunchHomePath(accountId, { create: false })
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(getOrcaCodexLaunchHostRootPath(), launchHomePath)) {
console.warn('[codex-home] Refusing to remove launch home outside host root:', 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 resolveOrcaCodexLaunchHomePath(
accountId: string | null,
options: { create: boolean }
): string {
return join(
getOrcaCodexLaunchHostRootPathWithOptions(options),
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 {
return readdirSync(sharedHomePath)
.filter((entryName) => SHARED_LAUNCH_ENTRY_NAMES.has(entryName))
.sort()
} catch {
return []
}
}
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)) {
removeLaunchEntryIfOwned(targetPath, launchHomePath, entryName, sourcePath)
return
}
if (targetAlreadyPointsToSource(targetPath, sourcePath)) {
markLaunchEntry(launchHomePath, entryName, sourcePath, 'link')
return
}
const ownedTarget =
existingMarker?.sourcePath === sourcePath && targetExistsForLaunchRemoval(targetPath)
if (targetExistsForLaunchRemoval(targetPath) && !ownedTarget) {
return
}
if (ownedTarget) {
removeLaunchEntry(targetPath)
}
try {
const sourceStat = lstatSync(sourcePath)
symlinkSync(
sourcePath,
targetPath,
sourceStat.isDirectory() && process.platform === 'win32' ? 'junction' : undefined
)
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 copyFallbackAllowed(sourcePath: string, entryName: string): boolean {
if (entryName === 'hooks.json') {
return false
}
const sourceStat = lstatSync(sourcePath)
return !sourceStat.isDirectory() || !MUTABLE_SHARED_DIRECTORY_ENTRIES.has(entryName)
}
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(): void {
const hostRootPath = getOrcaCodexLaunchHostRootPath()
let selectionEntries: string[]
try {
selectionEntries = readdirSync(hostRootPath)
} catch {
return
}
for (const selectionEntry of selectionEntries.sort()) {
const launchHomePath = join(hostRootPath, selectionEntry, 'home')
if (!existsSync(join(launchHomePath, LAUNCH_HOME_MARKER))) {
continue
}
reconcileMarkedMutableFiles(launchHomePath)
}
}
function reconcileMarkedMutableFiles(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
}
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
}
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<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) {
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 {
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 {
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 })
writeFileSync(
markerPath,
`${JSON.stringify(
{
version: LAUNCH_HOME_MARKER_VERSION,
sourcePath,
mode,
sourceDigest: digestPathIfFile(sourcePath),
targetDigest: digestPathIfFile(join(launchHomePath, entryName))
} 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<LaunchEntryMarker>
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 {
try {
return (
lstatSync(targetPath).isSymbolicLink() &&
linkTargetsMatch(readlinkSync(targetPath), sourcePath)
)
} catch {
return false
}
}
function linkTargetsMatch(actualTarget: string, expectedTarget: string): boolean {
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')
}

View File

@ -995,7 +995,7 @@ describe('CodexHookService', () => {
expect(runtimeToml).not.toContain(':stop:0:0')
})
it('mirrors system Codex config while preserving runtime hook trust on hook install', () => {
it('preserves runtime Codex prefs and hook trust on hook install without a sync baseline', () => {
const systemCodexHome = join(tmpHome, '.codex')
mkdirSync(systemCodexHome, { recursive: true })
writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8')
@ -1019,12 +1019,12 @@ describe('CodexHookService', () => {
expect(status.state).toBe('installed')
const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
expect(trustConfig).toContain('model = "system-model"')
expect(trustConfig).toContain('model = "runtime-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 = "runtime-model"')
expect(trustConfig).not.toContain('model = "system-model"')
})
it('repairs duplicate managed SessionStart trust tables on restart install', () => {
@ -1112,9 +1112,9 @@ describe('CodexHookService', () => {
expect(status.state).toBe('installed')
const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
expect(trustConfig).toContain('model = "system-model"')
expect(trustConfig).toContain('model = "runtime-model"')
expect(trustConfig).toContain('[projects."/repo"]\ntrust_level = "untrusted"')
expect(trustConfig).toContain('[projects."/runtime-only"]\ntrust_level = "trusted"')
expect(trustConfig).not.toContain('model = "runtime-model"')
expect(trustConfig).not.toContain('model = "system-model"')
})
})

View File

@ -398,6 +398,9 @@ function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget):
error
)
}
if (target?.runtime !== 'wsl') {
return codexRuntimeHome!.refreshCurrentHostLaunchHome() ?? runtimeHomePath
}
return runtimeHomePath
}

View File

@ -175,6 +175,8 @@ function makeDisposable() {
}
describe('registerPtyHandlers', () => {
const testCodexHomePath =
process.platform === 'win32' ? 'C:\\tmp\\orca-codex-home' : '/tmp/orca-codex-home'
const handlers = new Map<string, (_event: unknown, args: unknown) => unknown>()
const mainWindow = {
isDestroyed: () => false,
@ -529,9 +531,9 @@ describe('registerPtyHandlers', () => {
})
it('injects the selected Codex home into Orca terminal PTYs', async () => {
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')
const env = await spawnAndGetEnv(undefined, undefined, () => testCodexHomePath)
expect(env.CODEX_HOME).toBe(testCodexHomePath)
expect(env.ORCA_CODEX_HOME).toBe(testCodexHomePath)
})
it('injects the OpenCode hook env into Orca terminal PTYs', async () => {
@ -866,10 +868,10 @@ describe('registerPtyHandlers', () => {
const env = await spawnAndGetEnv(
undefined,
{ CODEX_HOME: '/tmp/system-codex-home' },
() => '/tmp/orca-codex-home'
() => testCodexHomePath
)
expect(env.CODEX_HOME).toBe('/tmp/orca-codex-home')
expect(env.ORCA_CODEX_HOME).toBe('/tmp/orca-codex-home')
expect(env.CODEX_HOME).toBe(testCodexHomePath)
expect(env.ORCA_CODEX_HOME).toBe(testCodexHomePath)
})
it('injects explicit proxy settings into local PTY env', async () => {
@ -1075,9 +1077,9 @@ describe('registerPtyHandlers', () => {
})
it('injects the selected Codex home on the daemon path', async () => {
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')
const env = await daemonSpawnAndGetEnv({}, () => testCodexHomePath)
expect(env.CODEX_HOME).toBe(testCodexHomePath)
expect(env.ORCA_CODEX_HOME).toBe(testCodexHomePath)
})
it('injects explicit proxy settings on the daemon path', async () => {