fix(codex): stop rebuilding shared Codex state from a read that failed (STA-4823)

Six shared files were rebuilt, erased or reported healthy after a read that had
only failed. Batch A of the STA-4606 split: every one of these is reachable and
testable on the host lane, so none of them wait on the WSL work.

- `config-toml-trust.ts` upsertHookTrustEntries: `existsSync` reported a locked
  config.toml as absent, so the base content became '' and the upsert rewrote
  the file from the trust entries alone — a trust-only stub, with the user's
  model, provider, MCP servers, approvals and comments gone. It refuses now;
  every hook-service caller already turns that into "trust entries could not be
  written. Run /hooks in Codex to approve."
- `codex-trust-grant-ledger.ts`: an unreadable ledger degraded to empty and the
  next write persisted a file holding only the home being written, dropping
  every other home's grants. The write paths refuse; the read path still
  degrades, and a corrupt ledger is still rebuilt.
- `codex-pane-account-registry.ts`: an unreadable registry erased every pane's
  attribution AND cached that erasure, so it survived the file recovering. The
  failure is no longer cached, and both write sites refuse rather than persist a
  registry derived from an empty stand-in.
- `hooks-json-read.ts`: the read arm already separated "no hooks" from "could
  not read", but the `existsSync` arm in front of it returned a valid empty
  config for a file that could not be opened. One read now classifies both.
- `config-settings-baseline.ts`: absent, unparseable and unreadable all collapsed
  into `null`, so the snapshot rebuilt a baseline it could not read — recording
  an in-Codex edit as Orca's own write, after which promotion skips it forever.
- `config-sync-stall.ts`: an unreadable runtime config read as absent and the
  status reported `synced` while the mirror was refusing. It reports
  `managed-home-unavailable`, the existing reason for exactly this, rather than
  borrowing a source-side one and blaming the wrong path.

Absent and malformed still rebuild throughout — resetting corrupt state is the
intent, and conflating it with unreadable would wedge a user on a broken file.
This commit is contained in:
Brennan Benson
2026-08-23 11:41:35 -07:00
parent e3327c2f31
commit 40df349308
8 changed files with 579 additions and 38 deletions
+11 -9
View File
@@ -1,4 +1,5 @@
import { existsSync, readFileSync } from 'node:fs'
import { readFileSync } from 'node:fs'
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
import type { HooksConfig } from './installer-utils'
export function isPlainObject(value: unknown): value is Record<string, unknown> {
@@ -26,16 +27,17 @@ export function parseHooksJsonText(raw: string): HooksConfig | null {
// bytes it was derived from; the raw snapshot and the parse must come from one
// read or a concurrent save can slip between them unnoticed.
export function readHooksJsonWithRaw(configPath: string): HooksJsonSnapshot {
if (!existsSync(configPath)) {
return { raw: null, config: {} }
}
let raw: string
// Why: the read arm below already separates "no hooks configured" from "could
// not read", but the `existsSync` arm in front of it did not — it returned a
// VALID EMPTY config for a file that merely could not be opened, and the
// installer then wrote generated hooks over it. One read classifies both, and
// closes the TOCTOU window between the two calls.
try {
raw = readFileSync(configPath, 'utf-8')
} catch {
return { raw: null, config: null }
const raw = readFileSync(configPath, 'utf-8')
return { raw, config: parseHooksJsonText(raw) }
} catch (error) {
return isDefinitiveAbsence(error) ? { raw: null, config: {} } : { raw: null, config: null }
}
return { raw, config: parseHooksJsonText(raw) }
}
export function readHooksJson(configPath: string): HooksConfig | null {
+44 -6
View File
@@ -1,4 +1,5 @@
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
import { dirname, join } from 'node:path'
import { getOrcaUserDataPath } from './codex-home-paths'
import type {
@@ -51,22 +52,47 @@ function getRegistryPath(): string {
return join(getOrcaUserDataPath(), 'codex-pane-accounts.json')
}
function readRegistry(): RegistryFile {
/**
* `null` means the registry could not be READ. That is not the same as "no
* panes are attributed", and it must never be cached: the old `catch { return
* null }` collapsed both into an empty registry, so one unreadable read erased
* every pane's account attribution AND pinned that erasure in `cachedRegistry`
* for the rest of the process, surviving the file becoming readable again.
*/
function readRegistryOrNull(): RegistryFile | null {
if (cachedRegistry) {
return cachedRegistry
}
cachedRegistry = parseRegistry(readRegistryFile())
let rawRegistry: string
try {
rawRegistry = readFileSync(getRegistryPath(), 'utf-8')
} catch (error) {
if (!isDefinitiveAbsence(error)) {
return null
}
cachedRegistry = parseRegistry(null)
return cachedRegistry
}
// Why: a corrupt registry still degrades to empty and IS cached — rebuilding
// unparseable state is the intent, and re-reading it every call would only
// repeat the parse failure.
cachedRegistry = parseRegistry(parseRegistryJson(rawRegistry))
return cachedRegistry
}
function readRegistryFile(): unknown {
function parseRegistryJson(rawRegistry: string): unknown {
try {
return JSON.parse(readFileSync(getRegistryPath(), 'utf-8'))
return JSON.parse(rawRegistry)
} catch {
return null
}
}
/** Read-only callers: an unreadable registry reports no attribution, uncached. */
function readRegistry(): RegistryFile {
return readRegistryOrNull() ?? { version: 2, panes: {} }
}
function parseRegistry(parsed: unknown): RegistryFile {
const empty: RegistryFile = { version: 2, panes: {} }
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
@@ -162,7 +188,13 @@ function writeRegistry(registry: RegistryFile): void {
* Records the account a PTY launched under. Pass null to forget a pinned pane.
*/
export function recordCodexPaneAccount(ptyId: string, record: CodexPaneAccountRecord | null): void {
const registry = readRegistry()
// Why: every write below persists the whole registry. Deriving it from an
// empty stand-in because the real one could not be read would drop every
// other pane's attribution on disk. Skip; the next record retries.
const registry = readRegistryOrNull()
if (!registry) {
return
}
if (!record) {
if (!(ptyId in registry.panes)) {
return
@@ -279,7 +311,13 @@ export function hasRecordedManagedHostCodexPane(): boolean {
/** Drops records whose daemon PTYs are authoritatively absent. */
export function reconcileCodexPaneAccountsWithLivePtys(livePtyIds: readonly string[]): void {
const registry = readRegistry()
// Why: this deletes every pane not in the live list. Against an empty stand-in
// it is a no-op, but the write it guards would still persist that stand-in
// over the real file, so refuse rather than reconcile a registry nobody read.
const registry = readRegistryOrNull()
if (!registry) {
return
}
const livePtyIdSet = new Set(livePtyIds)
let changed = false
for (const ptyId of Object.keys(registry.panes)) {
+27 -6
View File
@@ -1,4 +1,5 @@
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
import { dirname, join } from 'node:path'
import { getOrcaManagedCodexHomePath } from './codex-home-paths'
import { normalizeCodexProjectPathForLookup } from './config-toml-trust'
@@ -40,13 +41,22 @@ export function getCodexTrustGrantHomeKey(runtimeHomePath: string): string {
return normalizeCodexProjectPathForLookup(runtimeHomePath)
}
function readLedgerFile(ledgerPath: string): CodexTrustGrantLedgerFile {
/**
* `null` means the ledger could not be READ, which is different from an absent
* or corrupt one. A write derived from `empty` persists a file containing only
* the home being written, destroying every other home's grants — so the write
* paths refuse on `null` while the read path keeps degrading.
*/
function readLedgerFileOrNull(ledgerPath: string): CodexTrustGrantLedgerFile | null {
const empty: CodexTrustGrantLedgerFile = { version: 1, homes: {} }
if (!existsSync(ledgerPath)) {
return empty
let rawLedger: string
try {
rawLedger = readFileSync(ledgerPath, 'utf-8')
} catch (error) {
return isDefinitiveAbsence(error) ? empty : null
}
try {
const parsed: unknown = JSON.parse(readFileSync(ledgerPath, 'utf-8'))
const parsed: unknown = JSON.parse(rawLedger)
if (
!parsed ||
typeof parsed !== 'object' ||
@@ -67,6 +77,11 @@ function readLedgerFile(ledgerPath: string): CodexTrustGrantLedgerFile {
}
}
/** Corrupt or absent still degrades to empty — rebuilding those IS the intent. */
function readLedgerFile(ledgerPath: string): CodexTrustGrantLedgerFile {
return readLedgerFileOrNull(ledgerPath) ?? { version: 1, homes: {} }
}
function persistLedgerFile(ledgerPath: string, file: CodexTrustGrantLedgerFile): void {
mkdirSync(dirname(ledgerPath), { recursive: true, mode: 0o700 })
writeFileSync(ledgerPath, `${JSON.stringify(file, null, 2)}\n`, {
@@ -94,7 +109,13 @@ export function writeCodexTrustGrantLedgerHome(
home: CodexTrustGrantLedgerHome,
ledgerPath = getCodexTrustGrantLedgerPath()
): void {
const file = readLedgerFile(ledgerPath)
const file = readLedgerFileOrNull(ledgerPath)
if (!file) {
// Why: writing a file derived from `empty` would drop every other home's
// grants, and those homes would then be re-prompted for trust they had
// already granted. Skip this write; the next grant retries.
return
}
file.homes[getCodexTrustGrantHomeKey(runtimeHomePath)] = home
persistLedgerFile(ledgerPath, file)
}
+41 -5
View File
@@ -1,4 +1,5 @@
import { existsSync, writeFileSync } from 'node:fs'
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
import { join } from 'node:path'
import { readAgentStateFileSync, readAgentStateJsonFileSync } from '../agent-state-file-reader'
@@ -20,11 +21,39 @@ type StoredSettingsBaseline = {
conflicts?: Record<string, CodexSettingsConflict>
}
export function readCodexSettingsBaseline(runtimeHomePath: string): CodexSettingsBaseline | null {
/**
* Why callers need three answers, not two: the baseline records what Orca last
* mirrored, so `null` drives "there is nothing to compare against, bootstrap
* one". For a file that merely could not be READ, bootstrapping overwrites the
* record of a change the user made inside Codex, and the next mirror pass then
* writes the old value back over their edit. Absent and unparseable still map
* to `absent` — rebuilding those is the intent.
*/
export type CodexSettingsBaselineObservation =
| { kind: 'present'; baseline: CodexSettingsBaseline }
| { kind: 'absent' }
| { kind: 'indeterminate' }
export function observeCodexSettingsBaseline(
runtimeHomePath: string
): CodexSettingsBaselineObservation {
const baselinePath = getCodexSettingsBaselinePath(runtimeHomePath)
if (!existsSync(baselinePath)) {
return null
const baseline = readParsedCodexSettingsBaseline(baselinePath)
if (baseline === 'unreadable') {
return { kind: 'indeterminate' }
}
return baseline ? { kind: 'present', baseline } : { kind: 'absent' }
}
/** Absent and unreadable both collapse to `null`; use the observation to tell them apart. */
export function readCodexSettingsBaseline(runtimeHomePath: string): CodexSettingsBaseline | null {
const observation = observeCodexSettingsBaseline(runtimeHomePath)
return observation.kind === 'present' ? observation.baseline : null
}
function readParsedCodexSettingsBaseline(
baselinePath: string
): CodexSettingsBaseline | null | 'unreadable' {
try {
const parsed: unknown = readAgentStateJsonFileSync(baselinePath)
if (!isStoredSettingsBaseline(parsed)) {
@@ -46,11 +75,18 @@ export function readCodexSettingsBaseline(runtimeHomePath: string): CodexSetting
}
}
return { settings, conflicts }
} catch {
return null
} catch (error) {
// Why: a malformed baseline is still `null` — resetting corrupt state is the
// intent, and only a read that FAILED must be preserved.
return isDefinitiveAbsence(error) || isMalformedBaselineError(error) ? null : 'unreadable'
}
}
/** Why: `readAgentStateJsonFileSync` throws for a parse failure and for a size cap alike. */
function isMalformedBaselineError(error: unknown): boolean {
return error instanceof SyntaxError
}
export function writeCodexSettingsBaseline(
runtimeHomePath: string,
baseline: CodexSettingsBaseline
+17 -3
View File
@@ -14,7 +14,7 @@ import {
import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path'
import { tuiStructuredKey, upsertPromotedSettingsInContent } from './codex-config-settings-upsert'
import {
readCodexSettingsBaseline,
observeCodexSettingsBaseline,
writeCodexSettingsBaseline,
type CodexSettingsBaseline,
type CodexSettingsConflict
@@ -163,6 +163,13 @@ export function snapshotCodexRuntimeSettingsBaseline(
runtimeHomePath = getOrcaManagedCodexHomePath(),
conflicts: ReadonlyMap<string, CodexSettingsConflict> = new Map()
): void {
// Why: overwriting a baseline that could not be read records whatever the
// runtime config says now as "Orca wrote this", so an edit the user made
// inside Codex since the last pass is reclassified and never promoted again.
// Absent and malformed are still rebuilt — that is what this function is for.
if (observeCodexSettingsBaseline(runtimeHomePath).kind === 'indeterminate') {
return
}
try {
const runtimeTomlPath = join(runtimeHomePath, 'config.toml')
// Why: record an empty baseline even for a missing runtime config, so Codex's first write still diffs and promotes.
@@ -235,10 +242,17 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(
throw runtimeTomlObservation.error
}
// Why: without a baseline, a stale runtime value looks like a fresh in-Codex change; skip until the mirror writes one.
const baseline = readCodexSettingsBaseline(runtimeHomePath)
if (!baseline) {
const baselineObservation = observeCodexSettingsBaseline(runtimeHomePath)
if (baselineObservation.kind === 'indeterminate') {
// Why: an empty plan here lets the mirror proceed and write the system value
// back over an in-Codex edit this baseline would have identified. The caller
// turns a throw into the existing stall-and-retry null.
throw new Error('Codex settings baseline could not be read')
}
if (baselineObservation.kind === 'absent') {
return emptyPromotionPlan()
}
const baseline = baselineObservation.baseline
const runtimeValues = readPromotedSettingValues(runtimeTomlPath)
const systemValues = readPromotedSettingValues(systemTomlPath)
const updates = new Map<string, string>()
+15 -8
View File
@@ -1,6 +1,5 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { readAgentStateFileSync } from '../agent-state-file-reader'
import { observeAgentStateFile, observeResolvedPathEntry } from './codex-path-observation'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
import type { CodexSettingsPromotionHomes } from './config-settings-promotion'
import type {
@@ -28,20 +27,28 @@ export function getCodexConfigSyncStatus(
const runtimeConfigPath = join(homes.runtimeHomePath, 'config.toml')
// Why: a stall only withholds settings once a managed runtime config exists;
// without one the mirror seeds it and there is nothing yet to fall behind.
if (!existsSync(runtimeConfigPath)) {
// But `existsSync` said "no runtime config" for one that was merely locked,
// and this function then reported `synced` while the mirror was refusing —
// telling the user their edits had been applied when nothing had run.
const runtimeConfigObservation = observeResolvedPathEntry(runtimeConfigPath)
if (runtimeConfigObservation.kind === 'absent') {
return { state: 'synced', reason: null, systemConfigPath }
}
if (!existsSync(systemConfigPath)) {
if (runtimeConfigObservation.kind === 'indeterminate') {
// Why: the managed home is what could not be read, so say that rather than
// borrowing a source-side reason and blaming the wrong path.
return { state: 'stalled', reason: 'managed-home-unavailable', systemConfigPath }
}
const systemConfigObservation = observeAgentStateFile(systemConfigPath)
if (systemConfigObservation.kind === 'absent') {
return { state: 'stalled', reason: 'missing-source', systemConfigPath }
}
let rawSystemConfig: string
try {
rawSystemConfig = readAgentStateFileSync(systemConfigPath)
} catch {
if (systemConfigObservation.kind === 'indeterminate') {
// Why: the mirror aborts on an unreadable source too, so report the stall
// rather than claiming a sync that cannot happen.
return { state: 'stalled', reason: 'unreadable-source', systemConfigPath }
}
const rawSystemConfig = systemConfigObservation.value
if (rawSystemConfig.trim() === '') {
return { state: 'stalled', reason: 'blank-source', systemConfigPath }
}
+12 -1
View File
@@ -12,6 +12,7 @@ import {
import { basename, dirname, join, posix as pathPosix, win32 as pathWin32 } from 'node:path'
import { createHash, randomUUID } from 'node:crypto'
import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils'
import { observe } from './codex-path-observation'
import { foldWslUncPathCaseInsensitiveParts } from '../../shared/wsl-paths'
import { writeRollingFileBackup } from '../rolling-file-backup'
import {
@@ -337,7 +338,17 @@ export function upsertHookTrustEntries(
configPath: string,
entries: readonly CodexTrustEntry[]
): void {
const existing = existsSync(configPath) ? readTomlFile(configPath) : ''
// Why: `existsSync` answered `false` for a locked config exactly as for an
// absent one, so `existing` became '' and the upsert below rebuilt the file
// from the trust entries alone — replacing the user's model, provider, MCP
// servers, approvals and comments with a trust-only stub. Only a definitive
// absence may seed from empty; every caller in hook-service already turns a
// throw here into "trust entries could not be written. Run /hooks in Codex".
const observation = observe(() => readTomlFile(configPath))
if (observation.kind === 'indeterminate') {
throw observation.error
}
const existing = observation.kind === 'present' ? observation.value : ''
const updated = upsertHookTrustEntriesInContent(existing, entries)
if (updated === existing) {
return
@@ -0,0 +1,412 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFs from 'node:fs'
import type * as NodeOs from 'node:os'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
// STA-4823: six shared Codex state files were rebuilt, erased or reported as
// healthy after a read that had merely failed. Every one is reachable on the
// host lane; the WSL-specific half of the catalogue stays in STA-4606.
const denials = vi.hoisted(() => {
const state = {
paths: new Set<string>(),
readOnlyPaths: new Set<string>(),
existenceDeniedPaths: new Set<string>(),
deny(path: string): void {
state.paths.add(path)
},
/**
* Content reads fail but `existsSync` still says the file is there — the
* shape a Windows sharing violation takes, as distinct from `deny`, which
* models an ACL denial where `existsSync` itself reports false.
*/
denyReads(path: string): void {
state.readOnlyPaths.add(path)
},
/**
* `existsSync` reports false and content reads throw, but `statSync` and the
* atomic replace still work. This is the shape that actually loses data: the
* caller concludes "no file here", builds fresh content, and the rename
* lands because rename needs permission on the DIRECTORY, not the target.
* Guarding the write as well would let a test pass because the write failed
* rather than because the fix refused.
*/
denyExistence(path: string): void {
state.existenceDeniedPaths.add(path)
},
release(path: string): void {
state.paths.delete(path)
},
reset(): void {
state.paths.clear()
state.readOnlyPaths.clear()
state.existenceDeniedPaths.clear()
},
check(target: unknown, syscall: string, readOnly = false): void {
if (typeof target !== 'string') {
return
}
const readDenied =
readOnly && (state.readOnlyPaths.has(target) || state.existenceDeniedPaths.has(target))
if (!state.paths.has(target) && !readDenied) {
return
}
const error: NodeJS.ErrnoException = new Error(
`EPERM: operation not permitted, ${syscall} '${target}'`
)
error.code = 'EPERM'
error.errno = -4048
error.syscall = syscall
error.path = target
throw error
}
}
return state
})
// One denial modelled coherently: a denied path throws from every read AND
// reports false from existsSync. Faulting one but not another lets a test pass
// against code that still consults the one left healthy.
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFs>()
const guard = (fn: unknown, syscall: string): unknown => {
const original = fn as (...args: unknown[]) => unknown
const wrapped = (...args: unknown[]): unknown => {
denials.check(args[0], syscall)
return original(...args)
}
return Object.assign(wrapped, original)
}
const patched: Record<string, unknown> = {
...actual,
readFileSync: Object.assign((...args: unknown[]): unknown => {
denials.check(args[0], 'read', true)
return (actual.readFileSync as (...a: unknown[]) => unknown)(...args)
}, actual.readFileSync),
// Why: the size-capped agent-state reader opens with `openSync`, so leaving
// it unguarded makes a denial invisible to every config/baseline read. Guard
// it for READ intent only — a write opens 'w'/'wx'/'a', and the atomic
// replace those writes perform must stay healthy (see `deny` above).
openSync: Object.assign((...args: unknown[]): unknown => {
const flags = args[1]
const isRead = flags === undefined || (typeof flags === 'string' && flags.startsWith('r'))
if (isRead) {
denials.check(args[0], 'open', true)
}
return (actual.openSync as (...a: unknown[]) => unknown)(...args)
}, actual.openSync),
statSync: guard(actual.statSync, 'stat'),
lstatSync: guard(actual.lstatSync, 'lstat'),
existsSync: Object.assign(
(...args: unknown[]): boolean =>
typeof args[0] === 'string' &&
(denials.paths.has(args[0]) || denials.existenceDeniedPaths.has(args[0]))
? false
: actual.existsSync(args[0] as string),
actual.existsSync
)
}
return { ...patched, default: patched }
})
const { getPathMock, homedirMock } = vi.hoisted(() => ({
getPathMock: vi.fn<(name: string) => string>(),
homedirMock: vi.fn<() => string>()
}))
vi.mock('electron', () => ({ app: { getPath: getPathMock } }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof NodeOs>('node:os')
return { ...actual, homedir: homedirMock }
})
const realFs = await vi.importActual<typeof NodeFs>('node:fs')
const { upsertHookTrustEntries, readHookTrustEntries } = await import('./config-toml-trust')
const {
writeCodexTrustGrantLedgerHome,
readCodexTrustGrantLedgerHome,
getCodexTrustGrantLedgerPath
} = await import('./codex-trust-grant-ledger')
const { readHooksJson } = await import('../agent-hooks/hooks-json-read')
const { snapshotCodexRuntimeSettingsBaseline } = await import('./config-settings-promotion')
const { getCodexConfigSyncStatus } = await import('./config-sync-stall')
const paneRegistry = await import('./codex-pane-account-registry')
let fakeHomeDir: string
let userDataDir: string
let runtimeHomePath: string
let previousUserDataPath: string | undefined
const systemHome = (): string => join(fakeHomeDir, '.codex')
const baselinePath = (): string => join(runtimeHomePath, '.orca-config-settings-baseline.json')
beforeEach(() => {
denials.reset()
fakeHomeDir = realFs.mkdtempSync(join(tmpdir(), 'orca-sta4823-home-'))
userDataDir = realFs.mkdtempSync(join(tmpdir(), 'orca-sta4823-data-'))
runtimeHomePath = join(userDataDir, 'codex-runtime-home', 'home')
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
process.env.ORCA_USER_DATA_PATH = userDataDir
homedirMock.mockReturnValue(fakeHomeDir)
getPathMock.mockImplementation((name: string) => {
if (name === 'userData') {
return userDataDir
}
throw new Error(`unexpected app.getPath(${name})`)
})
realFs.mkdirSync(runtimeHomePath, { recursive: true })
realFs.mkdirSync(systemHome(), { recursive: true })
paneRegistry._internals.resetCache()
})
afterEach(() => {
denials.reset()
paneRegistry._internals.resetCache()
realFs.rmSync(fakeHomeDir, { recursive: true, force: true })
realFs.rmSync(userDataDir, { recursive: true, force: true })
if (previousUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
}
vi.clearAllMocks()
})
describe('STA-4823 D29 — an unreadable config.toml must not become a trust-only stub', () => {
const USER_CONFIG = [
'model = "gpt-5.1-codex-max"',
'model_provider = "openai"',
'',
'# my notes',
'[mcp_servers.local]',
'command = "run-me"',
''
].join('\n')
const ENTRY = [
{
sourcePath: '/tmp/hooks.json',
eventLabel: 'stop',
groupIndex: 0,
hookIndex: 0,
trustedHash: 'sha256:x',
enabled: true
}
]
it('refuses the write rather than rebuilding the file from the trust entries alone', () => {
const configPath = join(runtimeHomePath, 'config.toml')
realFs.writeFileSync(configPath, USER_CONFIG, 'utf-8')
denials.denyExistence(configPath)
// Every hook-service caller already turns this throw into "trust entries
// could not be written. Run /hooks in Codex to approve."
expect(() => upsertHookTrustEntries(configPath, ENTRY as never)).toThrow('EPERM')
// Before the fix, existsSync said the file was gone, `existing` became '',
// and the upsert wrote a config containing ONLY the trust table — the
// user's model, provider, MCP servers and comments all discarded.
expect(realFs.readFileSync(configPath, 'utf-8')).toBe(USER_CONFIG)
})
it('still seeds a config.toml that does not exist yet', () => {
const configPath = join(runtimeHomePath, 'config.toml')
// Why: seeding from empty is correct for a genuine absence, and is how a
// fresh managed home gets its trust entries at all.
upsertHookTrustEntries(configPath, ENTRY as never)
expect(readHookTrustEntries(configPath).size).toBeGreaterThan(0)
})
})
describe('STA-4823 D30 — an unreadable trust-grant ledger must not be rewritten from empty', () => {
const OTHER_HOME = '/some/other/managed/home'
function seedLedgerWithAnotherHome(): string {
const ledgerPath = getCodexTrustGrantLedgerPath()
writeCodexTrustGrantLedgerHome(
OTHER_HOME,
{ entries: { 'a:b': { trustedHash: 'sha256:other' } } } as never,
ledgerPath
)
return ledgerPath
}
it('skips the write instead of dropping every other home', () => {
const ledgerPath = seedLedgerWithAnotherHome()
const before = realFs.readFileSync(ledgerPath, 'utf-8')
denials.deny(ledgerPath)
writeCodexTrustGrantLedgerHome(
runtimeHomePath,
{ entries: { 'c:d': { trustedHash: 'sha256:mine' } } } as never,
ledgerPath
)
// Before the fix the read degraded to an empty ledger and the write
// persisted a file holding only this home, so every other home lost its
// grants and would be re-prompted for trust it had already given.
expect(realFs.readFileSync(ledgerPath, 'utf-8')).toBe(before)
})
it('still records a home when the ledger is readable', () => {
const ledgerPath = seedLedgerWithAnotherHome()
// Why: the anchor. Refusing whenever a read fails would stop the ledger
// ever recording a grant.
writeCodexTrustGrantLedgerHome(
runtimeHomePath,
{ entries: { 'c:d': { trustedHash: 'sha256:mine' } } } as never,
ledgerPath
)
expect(readCodexTrustGrantLedgerHome(runtimeHomePath, ledgerPath)).not.toBeNull()
expect(readCodexTrustGrantLedgerHome(OTHER_HOME, ledgerPath)).not.toBeNull()
})
})
describe('STA-4823 D33 — an unreadable hooks.json is not an empty hooks.json', () => {
it('reports a failed read as unknown rather than as no hooks configured', () => {
const hooksPath = join(runtimeHomePath, 'hooks.json')
realFs.writeFileSync(hooksPath, JSON.stringify({ hooks: { Stop: [] } }), 'utf-8')
denials.deny(hooksPath)
// The read arm already returned null for a failed read; the existsSync arm
// in front of it returned a VALID EMPTY config, and the installer then
// wrote generated hooks over the user's file.
expect(readHooksJson(hooksPath)).toBeNull()
})
it('still reports a genuinely absent hooks.json as an empty config', () => {
// Why: absence really does mean "no hooks configured", and the installer
// depends on that to seed one.
expect(readHooksJson(join(runtimeHomePath, 'hooks.json'))).toEqual({})
})
})
describe('STA-4823 D26 — an unreadable settings baseline must not be overwritten', () => {
function seedBaseline(): string {
realFs.writeFileSync(
baselinePath(),
`${JSON.stringify({ version: 2, settings: { model: 'old-model' } })}\n`,
'utf-8'
)
return realFs.readFileSync(baselinePath(), 'utf-8')
}
it('leaves the record alone so the pending edit can still be promoted', () => {
const before = seedBaseline()
realFs.writeFileSync(
join(runtimeHomePath, 'config.toml'),
'model = "edited-in-codex"\n',
'utf-8'
)
denials.deny(baselinePath())
snapshotCodexRuntimeSettingsBaseline(runtimeHomePath)
// Before the fix this recorded "edited-in-codex" as Orca's own write, so
// the user's in-Codex change was never promoted and the next mirror pass
// wrote the old value back over it.
expect(realFs.readFileSync(baselinePath(), 'utf-8')).toBe(before)
})
it('still replaces a baseline that is present but malformed', () => {
realFs.writeFileSync(baselinePath(), '{ not json', 'utf-8')
realFs.writeFileSync(join(runtimeHomePath, 'config.toml'), 'model = "m"\n', 'utf-8')
// Why: a corrupt baseline carries no information and resetting it IS the
// intent. Only the unreadable case may be preserved, or a user is wedged
// on a broken file forever.
snapshotCodexRuntimeSettingsBaseline(runtimeHomePath)
// Asserted against the file rather than the new observation API, so this
// anchor still means something when the fix is reverted.
expect(JSON.parse(realFs.readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 2 })
})
})
describe('STA-4823 D15 — the sync status must not report synced while the mirror refuses', () => {
it('reports the managed home as unavailable when its config cannot be read', () => {
const runtimeConfigPath = join(runtimeHomePath, 'config.toml')
realFs.writeFileSync(runtimeConfigPath, 'model = "m"\n', 'utf-8')
realFs.writeFileSync(join(systemHome(), 'config.toml'), 'model = "s"\n', 'utf-8')
denials.deny(runtimeConfigPath)
const status = getCodexConfigSyncStatus({ runtimeHomePath, systemHomePath: systemHome() })
// Before the fix existsSync reported the locked runtime config as absent
// and this returned `synced` — telling the user their edits had been
// applied while nothing had run.
expect(status).toMatchObject({ state: 'stalled', reason: 'managed-home-unavailable' })
})
it('still reports synced when no runtime config exists yet', () => {
realFs.writeFileSync(join(systemHome(), 'config.toml'), 'model = "s"\n', 'utf-8')
// Why: with no managed config the mirror seeds one; there is nothing to
// have fallen behind, and warning here would fire on every fresh install.
expect(
getCodexConfigSyncStatus({ runtimeHomePath, systemHomePath: systemHome() })
).toMatchObject({ state: 'synced', reason: null })
})
})
describe('STA-4823 D31 — an unreadable pane registry must not erase every attribution', () => {
const PANE = 'pty-1'
const OTHER_PANE = 'pty-2'
const registryPath = (): string => join(userDataDir, 'codex-pane-accounts.json')
function seedTwoAttributedPanes(): void {
paneRegistry.recordCodexPaneAccount(PANE, {
selectionKey: 'host',
accountId: 'account-1',
homeRoute: 'account-home'
} as never)
paneRegistry.recordCodexPaneAccount(OTHER_PANE, {
selectionKey: 'host',
accountId: 'account-2',
homeRoute: 'account-home'
} as never)
paneRegistry._internals.resetCache()
}
it('does not persist an empty registry over the real one', () => {
seedTwoAttributedPanes()
const before = realFs.readFileSync(registryPath(), 'utf-8')
denials.deny(registryPath())
paneRegistry.recordCodexPaneAccount('pty-3', {
selectionKey: 'host',
accountId: 'account-3',
homeRoute: 'account-home'
} as never)
// Before the fix the read degraded to an empty registry and this write
// persisted it, dropping every other pane's account attribution on disk.
expect(realFs.readFileSync(registryPath(), 'utf-8')).toBe(before)
})
it('does not cache the erasure, so attribution returns when the file does', () => {
seedTwoAttributedPanes()
denials.deny(registryPath())
expect(paneRegistry.getCodexPaneAccount(PANE)).toBeNull()
denials.release(registryPath())
// THE CACHE is the second half of this defect: one unreadable read used to
// pin the empty registry in memory for the rest of the process, so the
// attribution never came back even after the file did.
expect(paneRegistry.getCodexPaneAccount(PANE)).toMatchObject({ accountId: 'account-1' })
})
it('still reports no attribution when the registry genuinely does not exist', () => {
// Why: a fresh install has no registry, and that really is "no panes are
// attributed". Refusing here would make every first launch look degraded.
expect(paneRegistry.getCodexPaneAccount(PANE)).toBeNull()
})
})