From 6bb4f49f2ef03199a461864489bab843645c9701 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:05:25 -0700 Subject: [PATCH] fix(codex): preserve ordinary managed-home settings --- src/main/codex/codex-config-mirror.ts | 11 +- ...fig-ordinary-settings-preservation.test.ts | 201 +++++++++++++++++ .../config-settings-baseline-upgrade.test.ts | 59 ++++- .../codex/config-settings-baseline-v3.test.ts | 210 ++++++++++++++++++ src/main/codex/config-settings-baseline.ts | 150 +++++++++++-- .../config-settings-conflict-resolution.ts | 22 ++ .../codex/config-settings-promotion.test.ts | 5 +- src/main/codex/config-settings-promotion.ts | 140 ++++++++++-- .../codex/config-toml-ordinary-settings.ts | 164 +++++++++++++- ...hared-state-survives-a-failed-read.test.ts | 19 +- 10 files changed, 922 insertions(+), 59 deletions(-) create mode 100644 src/main/codex/config-settings-baseline-v3.test.ts diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index f3d96a1064c..24a9822830a 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -87,7 +87,9 @@ export function syncSystemConfigIntoManagedCodexHome( // baseline at all, and promotion stays inert until one exists — bootstrap // it, since nothing is promotable yet and so nothing can be stranded. if (!readCodexSettingsBaseline(homes.runtimeHomePath)) { - snapshotCodexRuntimeSettingsBaseline(homes.runtimeHomePath) + snapshotCodexRuntimeSettingsBaseline(homes.runtimeHomePath, new Map(), { + sourceIsAuthoritative: false + }) } return } @@ -97,7 +99,8 @@ export function syncSystemConfigIntoManagedCodexHome( homes.runtimeHomePath, new Map( [...promotionPlan.conflicts].filter(([key]) => mirrorResult.preservedConflictKeys.has(key)) - ) + ), + { sourceIsAuthoritative: true } ) } @@ -179,9 +182,7 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( // it would erase every ordinary setting from an existing managed runtime, and // a 0-byte file is what a half-written or unhydrated cloud-synced home shows. if (rawSystemConfig.trim() === '') { - return runtimeConfigExists - ? { status: 'skipped-missing-source' } - : { status: 'mirrored', preservedConflictKeys: new Set() } + return { status: 'skipped-missing-source' } } const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(systemHomePath) diff --git a/src/main/codex/config-ordinary-settings-preservation.test.ts b/src/main/codex/config-ordinary-settings-preservation.test.ts index 918ea086e23..d2f22043bf6 100644 --- a/src/main/codex/config-ordinary-settings-preservation.test.ts +++ b/src/main/codex/config-ordinary-settings-preservation.test.ts @@ -53,6 +53,10 @@ function runtimeConfigPath(): string { return join(runtimeHomeDir(), 'config.toml') } +function baselinePath(): string { + return join(runtimeHomeDir(), '.orca-config-settings-baseline.json') +} + function writeSystemConfig(content: string): void { mkdirSync(join(tmpHome, '.codex'), { recursive: true }) writeFileSync(systemConfigPath(), content, 'utf-8') @@ -127,6 +131,30 @@ describe('ordinary Codex settings survive a managed-home remirror', () => { expect(existsSync(systemConfigPath())).toBe(true) }) + it('keeps a second runtime edit local instead of promoting an unlisted key', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\npersonality = "nerdy"\n') + syncSystemConfigIntoManagedCodexHome() + setRuntimeConfig('model = "gpt-5"\npersonality = "pragmatic"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readRuntimeConfig()).toContain('personality = "pragmatic"') + expect(readSystemConfig()).not.toContain('personality') + }) + + it('mirrors a brand-new system ordinary key after an aligned snapshot', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + writeSystemConfig('model = "gpt-5"\npersonality = "nerdy"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readRuntimeConfig()).toContain('personality = "nerdy"') + expect(readSystemConfig()).toContain('personality = "nerdy"') + }) + it('carries an unlisted [tui] neighbor through a remirror', () => { writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\n') syncSystemConfigIntoManagedCodexHome() @@ -137,4 +165,177 @@ describe('ordinary Codex settings survive a managed-home remirror', () => { expect(readRuntimeConfig()).toContain('animations = false') expect(readSystemConfig()).not.toContain('animations') }) + + it('accepts a system-only change to an aligned unlisted key', () => { + writeSystemConfig('model = "gpt-5"\npersonality = "nerdy"\n') + syncSystemConfigIntoManagedCodexHome() + + writeSystemConfig('model = "gpt-5"\npersonality = "pragmatic"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readRuntimeConfig()).toContain('personality = "pragmatic"') + }) + + it('keeps a runtime deletion local until the system value changes', () => { + writeSystemConfig('model = "gpt-5"\npersonality = "nerdy"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + expect(readRuntimeConfig()).not.toContain('personality') + expect(readSystemConfig()).toContain('personality = "nerdy"') + + writeSystemConfig('model = "gpt-5"\npersonality = "pragmatic"\n') + syncSystemConfigIntoManagedCodexHome() + expect(readRuntimeConfig()).toContain('personality = "pragmatic"') + }) + + it('re-anchors an existing local conflict when the runtime value is deleted', () => { + writeSystemConfig('model = "gpt-5"\npersonality = "system"\n') + syncSystemConfigIntoManagedCodexHome() + setRuntimeConfig('model = "gpt-5"\npersonality = "runtime"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readRuntimeConfig()).not.toContain('personality') + expect(JSON.parse(readFileSync(baselinePath(), 'utf-8')).conflicts.personality).toEqual({ + runtime: null, + system: '"system"' + }) + }) + + it('keeps simultaneous unlisted divergence local and content-anchored', () => { + writeSystemConfig('model = "gpt-5"\npersonality = "ancestor"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('model = "gpt-5"\npersonality = "runtime"\n') + writeSystemConfig('model = "gpt-5"\npersonality = "system"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readRuntimeConfig()).toContain('personality = "runtime"') + expect(readSystemConfig()).toContain('personality = "system"') + expect(JSON.parse(readFileSync(baselinePath(), 'utf-8')).conflicts.personality).toEqual({ + runtime: '"runtime"', + system: '"system"' + }) + }) + + it('restores promoted values deleted from the runtime', () => { + writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\n') + syncSystemConfigIntoManagedCodexHome() + + setRuntimeConfig('[tui]\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readRuntimeConfig()).toContain('model = "gpt-5"') + expect(readRuntimeConfig()).toContain('theme = "dark"') + expect(readSystemConfig()).toContain('model = "gpt-5"') + expect(readSystemConfig()).toContain('theme = "dark"') + }) + + it('keeps legacy-baseline unlisted edits local while promoted edits write through', () => { + writeSystemConfig('model = "gpt-5"\npersonality = "system"\n') + syncSystemConfigIntoManagedCodexHome() + writeFileSync( + baselinePath(), + `${JSON.stringify({ version: 2, settings: { model: '"gpt-5"' } })}\n`, + 'utf-8' + ) + setRuntimeConfig('model = "o4"\npersonality = "runtime"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toContain('model = "o4"') + expect(readSystemConfig()).toContain('personality = "system"') + expect(readRuntimeConfig()).toContain('personality = "runtime"') + + setRuntimeConfig('model = "o4"\npersonality = "runtime-2"\n') + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).not.toContain('personality = "runtime-2"') + expect(readRuntimeConfig()).toContain('personality = "runtime-2"') + }) + + it('bootstraps missing source without treating unlisted runtime state as authoritative', () => { + setRuntimeConfig('model = "seeded"\npersonality = "local"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 2 }) + expect(JSON.parse(readFileSync(baselinePath(), 'utf-8')).sourceAuthority).toBeUndefined() + + writeSystemConfig('model = "seeded"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readRuntimeConfig()).toContain('personality = "local"') + expect(readSystemConfig()).not.toContain('personality') + expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ + version: 3, + sourceAuthority: 'mirrored' + }) + }) + + it('stalls absent and blank sources after an authoritative mirror', () => { + writeSystemConfig('model = "gpt-5"\npersonality = "system"\n') + syncSystemConfigIntoManagedCodexHome() + const baseline = readFileSync(baselinePath(), 'utf-8') + setRuntimeConfig('model = "o4"\npersonality = "local"\n') + + rmSync(systemConfigPath()) + syncSystemConfigIntoManagedCodexHome() + expect(readRuntimeConfig()).toContain('personality = "local"') + expect(readFileSync(baselinePath(), 'utf-8')).toBe(baseline) + + writeSystemConfig(' \n') + syncSystemConfigIntoManagedCodexHome() + expect(readRuntimeConfig()).toContain('model = "o4"') + expect(readFileSync(baselinePath(), 'utf-8')).toBe(baseline) + + writeSystemConfig('model = "gpt-5"\npersonality = "system"\n') + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toContain('model = "o4"') + expect(readRuntimeConfig()).toContain('personality = "local"') + }) + + it('keeps supported unlisted values local when seeding a missing system config', () => { + syncSystemConfigIntoManagedCodexHome() + setRuntimeConfig( + 'model = "o4"\npersonality = "local"\n"quoted-local" = true\nmultiline = """\nvalue\n"""\n\n[features]\nhooks = true\n\n[mcp_servers.local]\ncommand = "run"\n' + ) + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toContain('model = "o4"') + expect(readSystemConfig()).not.toContain('personality') + expect(readSystemConfig()).toContain('"quoted-local" = true') + expect(readSystemConfig()).toContain('multiline = """') + expect(readSystemConfig()).toContain('[features]') + expect(readSystemConfig()).toContain('[mcp_servers.local]') + expect(readRuntimeConfig()).toContain('personality = "local"') + }) + + it('excludes structured shapes and quoted unknown keys while preserving flat arrays', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + setRuntimeConfig( + 'model = "gpt-5"\ninline = { enabled = true }\nnested = [{ enabled = true }]\nflat = ["a", "b"]\n"quoted-local" = true\n' + ) + syncSystemConfigIntoManagedCodexHome() + + expect(readRuntimeConfig()).not.toContain('inline =') + expect(readRuntimeConfig()).not.toContain('nested =') + expect(readRuntimeConfig()).not.toContain('quoted-local') + expect(readRuntimeConfig()).toContain('flat = ["a", "b"]') + expect(readSystemConfig()).not.toContain('flat =') + }) + + it('lets table and dotted descendants win over colliding runtime scalars', () => { + writeSystemConfig('model = "gpt-5"\nfoo.bar = true\n\n[tui.theme]\nvariant = "dark"\n') + syncSystemConfigIntoManagedCodexHome() + setRuntimeConfig('model = "gpt-5"\nfoo = "scalar"\n\n[tui]\ntheme = "scalar"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readRuntimeConfig()).toContain('foo.bar = true') + expect(readRuntimeConfig()).not.toContain('foo = "scalar"') + expect(readRuntimeConfig()).toContain('[tui.theme]') + expect(readRuntimeConfig()).not.toContain('theme = "scalar"') + }) }) diff --git a/src/main/codex/config-settings-baseline-upgrade.test.ts b/src/main/codex/config-settings-baseline-upgrade.test.ts index 443f5f429ec..4bae025658a 100644 --- a/src/main/codex/config-settings-baseline-upgrade.test.ts +++ b/src/main/codex/config-settings-baseline-upgrade.test.ts @@ -73,6 +73,8 @@ function readBaseline(): { version: number settings: Record conflicts?: Record + knownPromotedKeys?: string[] + sourceAuthority?: string } { return JSON.parse(readFileSync(baselinePath(), 'utf-8')) } @@ -85,9 +87,11 @@ describe('Codex settings baseline schema upgrade', () => { syncSystemConfigIntoManagedCodexHome() expect(readBaseline()).toMatchObject({ - version: 2, - settings: { model: '"gpt-5"', 'tui.theme': '"dark"' } + version: 3, + settings: { model: '"gpt-5"', 'tui.theme': '"dark"' }, + sourceAuthority: 'mirrored' }) + expect(readBaseline().knownPromotedKeys).toContain('tui.theme') expect(readBaseline().conflicts).toBeUndefined() }) @@ -233,6 +237,57 @@ describe('Codex settings baseline schema upgrade', () => { }) }) + it('migrates a promoted key omitted from an older v3 promoted-key set', () => { + prepareLegacyState( + 'model = "gpt-5"\napproval_policy = "never"\n', + 'model = "gpt-5"\napproval_policy = "on-request"\n' + ) + writeFileSync( + baselinePath(), + `${JSON.stringify({ + version: 3, + sourceAuthority: 'mirrored', + knownPromotedKeys: ['model'], + settings: { model: '"gpt-5"', approval_policy: '"never"' } + })}\n`, + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('approval_policy = "never"') + expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('approval_policy = "on-request"') + expect(readBaseline().conflicts?.approval_policy).toEqual({ + runtime: '"on-request"', + system: '"never"' + }) + + writeFileSync(runtimeConfigPath(), 'model = "gpt-5"\napproval_policy = "chosen"\n', 'utf-8') + syncSystemConfigIntoManagedCodexHome() + + expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('approval_policy = "chosen"') + expect(readBaseline().conflicts).toBeUndefined() + }) + + it('retries a promoted conflict after its authoritative source disappears', () => { + prepareLegacyState('model = "gpt-5"\n', 'model = "gpt-5"\napproval_policy = "on-request"\n') + syncSystemConfigIntoManagedCodexHome() + const anchoredBaseline = readFileSync(baselinePath(), 'utf-8') + + rmSync(systemConfigPath()) + writeFileSync(runtimeConfigPath(), 'model = "gpt-5"\napproval_policy = "never"\n', 'utf-8') + syncSystemConfigIntoManagedCodexHome() + + expect(existsSync(systemConfigPath())).toBe(false) + expect(readFileSync(baselinePath(), 'utf-8')).toBe(anchoredBaseline) + + writeFileSync(systemConfigPath(), 'model = "gpt-5"\n', 'utf-8') + syncSystemConfigIntoManagedCodexHome() + + expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('approval_policy = "never"') + expect(readBaseline().conflicts).toBeUndefined() + }) + it('lets an incompatible system TOML shape win instead of stranding a conflict', () => { prepareLegacyState( 'model = "gpt-5"\ntui = { animations = false }\n', diff --git a/src/main/codex/config-settings-baseline-v3.test.ts b/src/main/codex/config-settings-baseline-v3.test.ts new file mode 100644 index 00000000000..78aba051fad --- /dev/null +++ b/src/main/codex/config-settings-baseline-v3.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as CodexFsUtils from '../codex-accounts/fs-utils' + +const { atomicState } = vi.hoisted(() => ({ + atomicState: { fail: false, guardedCalls: 0 } +})) + +vi.mock('../codex-accounts/fs-utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeFileAtomically: (...args: Parameters) => { + if (atomicState.fail) { + throw new Error('injected baseline rename failure') + } + return actual.writeFileAtomically(...args) + }, + writeFileAtomicallyIfUnchanged: ( + ...args: Parameters + ) => { + atomicState.guardedCalls += 1 + return actual.writeFileAtomicallyIfUnchanged(...args) + } + } +}) + +import { MAX_AGENT_STATE_FILE_BYTES } from '../agent-state-file-reader' +import { + getCodexSettingsBaselinePath, + readCodexSettingsBaseline, + writeCodexSettingsBaseline, + type CodexSettingsBaseline +} from './config-settings-baseline' + +let runtimeHomePath: string + +beforeEach(() => { + runtimeHomePath = mkdtempSync(join(tmpdir(), 'orca-codex-baseline-v3-')) + atomicState.fail = false + atomicState.guardedCalls = 0 +}) + +afterEach(() => { + rmSync(runtimeHomePath, { recursive: true, force: true }) + vi.clearAllMocks() +}) + +function baselinePath(): string { + return getCodexSettingsBaselinePath(runtimeHomePath) +} + +function makeBaseline( + settings: ReadonlyMap, + conflicts: CodexSettingsBaseline['conflicts'] = new Map() +): CodexSettingsBaseline { + return { + settings, + conflicts, + knownPromotedKeys: new Set(['model', 'tui.theme']), + sourceIsAuthoritative: true, + tracksAllOrdinarySettings: true + } +} + +function readStoredBaseline(): { + version: number + settings: Record + sourceAuthority?: string +} { + return JSON.parse(readFileSync(baselinePath(), 'utf-8')) +} + +describe('Codex settings baseline v3 persistence', () => { + it.each([ + { + name: 'setting', + value: { + version: 3, + sourceAuthority: 'mirrored', + knownPromotedKeys: ['model'], + settings: { model: 42 } + } + }, + { + name: 'conflict', + value: { + version: 3, + sourceAuthority: 'mirrored', + knownPromotedKeys: ['model'], + settings: {}, + conflicts: { model: { runtime: '"r"', system: false } } + } + }, + { + name: 'known promoted key', + value: { + version: 3, + sourceAuthority: 'mirrored', + knownPromotedKeys: ['model', 42], + settings: {} + } + }, + { + name: 'structured key', + value: { + version: 3, + sourceAuthority: 'mirrored', + knownPromotedKeys: ['model'], + settings: { '': '"invalid"' } + } + }, + { + name: 'conflict container', + value: { + version: 3, + sourceAuthority: 'mirrored', + knownPromotedKeys: ['model'], + settings: {}, + conflicts: [] + } + } + ])('rejects the whole baseline when a v3 $name is malformed', ({ value }) => { + writeFileSync(baselinePath(), JSON.stringify(value), 'utf-8') + + expect(readCodexSettingsBaseline(runtimeHomePath)).toBeNull() + }) + + it('falls back to readable v2 when an escaped promoted value exceeds the byte limit', () => { + const raw = `"${'\\'.repeat(MAX_AGENT_STATE_FILE_BYTES / 2 + 64)}"` + + writeCodexSettingsBaseline(runtimeHomePath, makeBaseline(new Map([['model', raw]]))) + + expect(statSync(baselinePath()).size).toBeLessThanOrEqual(MAX_AGENT_STATE_FILE_BYTES) + expect(readStoredBaseline()).toMatchObject({ + version: 2, + settings: {}, + sourceAuthority: 'mirrored' + }) + expect(readCodexSettingsBaseline(runtimeHomePath)).not.toBeNull() + }) + + it('keeps promoted ancestors when an unlisted value forces v2 fallback', () => { + const settings = new Map([ + ['model', '"gpt-5"'], + ['personality', `"${'x'.repeat(MAX_AGENT_STATE_FILE_BYTES)}"`] + ]) + + writeCodexSettingsBaseline(runtimeHomePath, makeBaseline(settings)) + + expect(readStoredBaseline()).toMatchObject({ + version: 2, + settings: { model: '"gpt-5"' }, + sourceAuthority: 'mirrored' + }) + }) + + it('bounds baselines with many distinct ordinary keys', () => { + const settings = new Map() + for (let index = 0; index < 48_000; index += 1) { + settings.set(`ordinary_${index}`, `"${'x'.repeat(64)}"`) + } + settings.set('model', '"gpt-5"') + + writeCodexSettingsBaseline(runtimeHomePath, makeBaseline(settings)) + + expect(readStoredBaseline()).toMatchObject({ version: 2, settings: { model: '"gpt-5"' } }) + expect(statSync(baselinePath()).size).toBeLessThanOrEqual(MAX_AGENT_STATE_FILE_BYTES) + }) + + it('uses UTF-8 byte length rather than JavaScript string length', () => { + const multibyte = '界'.repeat(Math.floor(MAX_AGENT_STATE_FILE_BYTES / 2)) + expect(multibyte.length).toBeLessThan(MAX_AGENT_STATE_FILE_BYTES) + + writeCodexSettingsBaseline( + runtimeHomePath, + makeBaseline( + new Map([ + ['model', '"gpt-5"'], + ['personality', `"${multibyte}"`] + ]) + ) + ) + + expect(readStoredBaseline()).toMatchObject({ version: 2, settings: { model: '"gpt-5"' } }) + }) + + it.skipIf(process.platform === 'win32')( + 'atomically writes baselines with owner-only mode', + () => { + writeCodexSettingsBaseline(runtimeHomePath, makeBaseline(new Map([['model', '"gpt-5"']]))) + + expect(statSync(baselinePath()).mode & 0o777).toBe(0o600) + expect(atomicState.guardedCalls).toBe(0) + } + ) + + it('keeps the prior readable baseline when replacement fails', () => { + writeCodexSettingsBaseline(runtimeHomePath, makeBaseline(new Map([['model', '"gpt-5"']]))) + const before = readFileSync(baselinePath(), 'utf-8') + atomicState.fail = true + + expect(() => + writeCodexSettingsBaseline(runtimeHomePath, makeBaseline(new Map([['model', '"o4"']]))) + ).toThrow('injected baseline rename failure') + expect(readFileSync(baselinePath(), 'utf-8')).toBe(before) + }) +}) diff --git a/src/main/codex/config-settings-baseline.ts b/src/main/codex/config-settings-baseline.ts index c771f3f7d37..88f039b7b4e 100644 --- a/src/main/codex/config-settings-baseline.ts +++ b/src/main/codex/config-settings-baseline.ts @@ -1,9 +1,13 @@ -import { writeFileSync } from 'node:fs' import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import { JsonTextStructureCapacityError } from '../../shared/json-text-structure-limit' import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' import { join } from 'node:path' -import { readAgentStateFileSync, readAgentStateJsonFileSync } from '../agent-state-file-reader' +import { + MAX_AGENT_STATE_FILE_BYTES, + readAgentStateFileSync, + readAgentStateJsonFileSync +} from '../agent-state-file-reader' +import { writeFileAtomically } from '../codex-accounts/fs-utils' const SETTINGS_BASELINE_FILE = '.orca-config-settings-baseline.json' @@ -15,12 +19,17 @@ export type CodexSettingsConflict = { export type CodexSettingsBaseline = { settings: ReadonlyMap conflicts: ReadonlyMap + knownPromotedKeys: ReadonlySet + sourceIsAuthoritative: boolean + tracksAllOrdinarySettings: boolean } type StoredSettingsBaseline = { - version: 1 | 2 + version: 1 | 2 | 3 settings: Record conflicts?: Record + knownPromotedKeys?: string[] + sourceAuthority?: 'mirrored' } /** @@ -59,11 +68,15 @@ function readParsedCodexSettingsBaseline( if (!isStoredSettingsBaseline(parsed)) { return null } - const settings = new Map( - Object.entries(parsed.settings).filter((entry): entry is [string, string | null] => { - return typeof entry[1] === 'string' || entry[1] === null - }) - ) + if (parsed.version === 3 && !hasStrictV3Members(parsed)) { + return null + } + const settings = new Map() + for (const [key, raw] of Object.entries(parsed.settings)) { + if (typeof raw === 'string' || raw === null) { + settings.set(key, raw) + } + } const conflicts = new Map() for (const [key, conflict] of Object.entries(parsed.conflicts ?? {})) { if ( @@ -74,7 +87,13 @@ function readParsedCodexSettingsBaseline( conflicts.set(key, conflict) } } - return { settings, conflicts } + return { + settings, + conflicts, + knownPromotedKeys: new Set(parsed.knownPromotedKeys ?? []), + sourceIsAuthoritative: parsed.sourceAuthority === 'mirrored', + tracksAllOrdinarySettings: parsed.version === 3 + } } catch (error) { // Why: invalid baseline state is still `null` — resetting it is the intent, // and only a read that FAILED must be preserved. @@ -95,15 +114,9 @@ export function writeCodexSettingsBaseline( runtimeHomePath: string, baseline: CodexSettingsBaseline ): void { - const file: StoredSettingsBaseline = { - version: 2, - settings: Object.fromEntries(baseline.settings) - } - if (baseline.conflicts.size > 0) { - file.conflicts = Object.fromEntries(baseline.conflicts) - } + const file = buildStoredSettingsBaseline(baseline) const baselinePath = getCodexSettingsBaselinePath(runtimeHomePath) - const serialized = `${JSON.stringify(file, null, 2)}\n` + const serialized = serializeBaseline(file) let existing: string | null = null try { existing = readAgentStateFileSync(baselinePath) @@ -117,7 +130,7 @@ export function writeCodexSettingsBaseline( if (existing === serialized) { return } - writeFileSync(baselinePath, serialized, { encoding: 'utf-8', mode: 0o600 }) + writeFileAtomically(baselinePath, serialized, { mode: 0o600 }) } export function getCodexSettingsBaselinePath(runtimeHomePath: string): string { @@ -130,9 +143,108 @@ function isStoredSettingsBaseline(value: unknown): value is StoredSettingsBaseli } const candidate = value as Partial return ( - (candidate.version === 1 || candidate.version === 2) && + (candidate.version === 1 || candidate.version === 2 || candidate.version === 3) && !!candidate.settings && typeof candidate.settings === 'object' && !Array.isArray(candidate.settings) ) } + +function hasStrictV3Members(baseline: StoredSettingsBaseline): boolean { + if ( + baseline.sourceAuthority !== 'mirrored' || + !Array.isArray(baseline.knownPromotedKeys) || + (baseline.conflicts !== undefined && + (!baseline.conflicts || + typeof baseline.conflicts !== 'object' || + Array.isArray(baseline.conflicts))) || + baseline.knownPromotedKeys.some( + (key) => typeof key !== 'string' || !isStoredStructuredKey(key) + ) || + new Set(baseline.knownPromotedKeys).size !== baseline.knownPromotedKeys.length + ) { + return false + } + if ( + Object.entries(baseline.settings).some( + ([key, raw]) => !isStoredStructuredKey(key) || (typeof raw !== 'string' && raw !== null) + ) + ) { + return false + } + return Object.entries(baseline.conflicts ?? {}).every( + ([key, conflict]) => + !Object.hasOwn(baseline.settings, key) && + isStoredStructuredKey(key) && + isCodexSettingsConflict(conflict) + ) +} + +function isStoredStructuredKey(key: string): boolean { + return /^(?:[A-Za-z0-9_-]+|tui\.[A-Za-z0-9_-]+)$/.test(key) +} + +function isCodexSettingsConflict(value: unknown): value is CodexSettingsConflict { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const conflict = value as Partial + return ( + (typeof conflict.runtime === 'string' || conflict.runtime === null) && + (typeof conflict.system === 'string' || conflict.system === null) + ) +} + +function buildStoredSettingsBaseline(baseline: CodexSettingsBaseline): StoredSettingsBaseline { + const preferred: StoredSettingsBaseline = { + version: baseline.tracksAllOrdinarySettings ? 3 : 2, + settings: Object.fromEntries(baseline.settings) + } + if (baseline.conflicts.size > 0) { + preferred.conflicts = Object.fromEntries(baseline.conflicts) + } + if (baseline.sourceIsAuthoritative) { + preferred.sourceAuthority = 'mirrored' + } + if (preferred.version === 3) { + preferred.knownPromotedKeys = [...baseline.knownPromotedKeys] + } + if (fitsBaselineReader(preferred)) { + return preferred + } + return buildBoundedLegacyBaseline(baseline) +} + +function buildBoundedLegacyBaseline(baseline: CodexSettingsBaseline): StoredSettingsBaseline { + const fallback: StoredSettingsBaseline = { version: 2, settings: {} } + if (baseline.sourceIsAuthoritative) { + fallback.sourceAuthority = 'mirrored' + } + if (baseline.conflicts.size > 0) { + fallback.conflicts = Object.fromEntries(baseline.conflicts) + } + if (!fitsBaselineReader(fallback)) { + throw new NodeFileReadTooLargeError( + Buffer.byteLength(serializeBaseline(fallback), 'utf-8'), + MAX_AGENT_STATE_FILE_BYTES + ) + } + for (const key of baseline.knownPromotedKeys) { + if (!baseline.settings.has(key)) { + continue + } + fallback.settings[key] = baseline.settings.get(key) ?? null + if (!fitsBaselineReader(fallback)) { + delete fallback.settings[key] + } + } + return fallback +} + +function fitsBaselineReader(file: StoredSettingsBaseline): boolean { + return Buffer.byteLength(serializeBaseline(file), 'utf-8') <= MAX_AGENT_STATE_FILE_BYTES +} + +function serializeBaseline(file: StoredSettingsBaseline): string { + return `${JSON.stringify(file, null, 2)}\n` +} diff --git a/src/main/codex/config-settings-conflict-resolution.ts b/src/main/codex/config-settings-conflict-resolution.ts index 1fc147f4829..e01a8f0a55c 100644 --- a/src/main/codex/config-settings-conflict-resolution.ts +++ b/src/main/codex/config-settings-conflict-resolution.ts @@ -33,3 +33,25 @@ export function resolveUntrackedCodexSetting( } return { action: 'preserve', conflict: existingConflict } } + +export function resolveUnlistedCodexSetting( + runtime: string | null, + system: string | null, + existingConflict?: CodexSettingsConflict +): CodexSettingsConflictResolution { + if (runtime === system) { + return { action: 'aligned' } + } + if (!existingConflict) { + return { action: 'preserve', conflict: { runtime, system } } + } + const runtimeChanged = runtime !== existingConflict.runtime + const systemChanged = system !== existingConflict.system + if (!runtimeChanged && systemChanged) { + return { action: 'use-system' } + } + if (runtimeChanged) { + return { action: 'preserve', conflict: { runtime, system } } + } + return { action: 'preserve', conflict: existingConflict } +} diff --git a/src/main/codex/config-settings-promotion.test.ts b/src/main/codex/config-settings-promotion.test.ts index 55d0fabf397..dd3f0ed3eb7 100644 --- a/src/main/codex/config-settings-promotion.test.ts +++ b/src/main/codex/config-settings-promotion.test.ts @@ -201,7 +201,10 @@ describe('codex settings write-back promotion', () => { simulateCodexSettingWrite('model', '"o4"') syncSystemConfigIntoManagedCodexHome() expect(readSystemConfig()).toBe('model = "gpt-5"\n') - expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 2 }) + expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ + version: 3, + sourceAuthority: 'mirrored' + }) simulateCodexSettingWrite('model', '"o4"') syncSystemConfigIntoManagedCodexHome() diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts index 5ecc89e3759..95f734be358 100644 --- a/src/main/codex/config-settings-promotion.ts +++ b/src/main/codex/config-settings-promotion.ts @@ -6,15 +6,21 @@ import { writeFileAtomically } from '../codex-accounts/fs-utils' import { parseWslUncPath } from '../../shared/wsl-paths' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { upsertPromotedSettingsInContent } from './codex-config-settings-upsert' +import { removePromotedSettingsFromContent } from './codex-config-settings-removal' import { observeCodexSettingsBaseline, writeCodexSettingsBaseline, type CodexSettingsBaseline, type CodexSettingsConflict } from './config-settings-baseline' -import { resolveUntrackedCodexSetting } from './config-settings-conflict-resolution' +import { + resolveUnlistedCodexSetting, + resolveUntrackedCodexSetting +} from './config-settings-conflict-resolution' import { collectOrdinaryCodexSettingKeys, + isOrdinarySettingBlocked, + isPromotedStructuredKey, parseOrdinaryCodexSettingValues, PROMOTED_STRUCTURED_KEYS, type OrdinaryCodexSettingValue @@ -23,7 +29,7 @@ import { extractOrdinaryCodexSettings } from './config-toml-runtime-owned-sectio // Why: the mirror reverts in-Codex config changes each launch; promotion salvages them by diffing the last baseline. -function readPromotedSettingValues(configPath: string): Map { +function readOrdinarySettingValues(configPath: string): Map { // Why: an unreadable config held no settings only in the sense that we could // not read them. Returning an empty map says the user cleared every promoted // value, and the write below then acts on that. @@ -38,27 +44,43 @@ function readPromotedSettingValues(configPath: string): Map = new Map() + conflicts: ReadonlyMap = new Map(), + options: { sourceIsAuthoritative?: boolean } = {} ): void { 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. - const runtimeValues = readPromotedSettingValues(runtimeTomlPath) + const runtimeValues = readOrdinarySettingValues(runtimeTomlPath) const settings = new Map() - for (const key of PROMOTED_STRUCTURED_KEYS) { + const sourceIsAuthoritative = options.sourceIsAuthoritative ?? true + const keys = sourceIsAuthoritative ? runtimeValues.keys() : PROMOTED_STRUCTURED_KEYS + for (const key of keys) { const value = runtimeValues.get(key) - if (!conflicts.has(key) && !value?.multiline) { + if (!conflicts.has(key) && !value?.multiline && !value?.unsupported) { // Why: explicit nulls distinguish a schema-aware absence from a key added by a later schema. settings.set(key, value?.raw ?? null) } } - writeCodexSettingsBaseline(runtimeHomePath, { settings, conflicts }) + for (const key of PROMOTED_STRUCTURED_KEYS) { + if (!conflicts.has(key) && !settings.has(key)) { + const value = runtimeValues.get(key) + if (!value?.multiline && !value?.unsupported) { + settings.set(key, value?.raw ?? null) + } + } + } + writeCodexSettingsBaseline(runtimeHomePath, { + settings, + conflicts, + knownPromotedKeys: new Set(PROMOTED_STRUCTURED_KEYS), + sourceIsAuthoritative, + tracksAllOrdinarySettings: sourceIsAuthoritative + }) } catch (error) { console.warn('[codex-settings-promotion] failed to snapshot settings baseline', error) } @@ -129,8 +151,26 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( return emptyPromotionPlan() } const baseline = baselineObservation.baseline - const runtimeValues = readPromotedSettingValues(runtimeTomlPath) - const systemValues = readPromotedSettingValues(systemTomlPath) + const runtimeValues = readOrdinarySettingValues(runtimeTomlPath) + const systemTomlObservation = observeAgentStateFile(systemTomlPath) + if (systemTomlObservation.kind === 'indeterminate') { + throw systemTomlObservation.error + } + const systemSourceIsBlank = + systemTomlObservation.kind === 'present' && systemTomlObservation.value.trim() === '' + if ( + baseline.sourceIsAuthoritative && + (systemTomlObservation.kind === 'absent' || systemSourceIsBlank) + ) { + throw new Error('Authoritative Codex config source is unavailable') + } + if (systemSourceIsBlank) { + return emptyPromotionPlan() + } + const systemValues = + systemTomlObservation.kind === 'present' + ? parseOrdinaryCodexSettingValues(systemTomlObservation.value) + : new Map() const updates = new Map() const conflicts = new Map() const runtimeValuesToPreserve = new Map() @@ -155,7 +195,7 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( // the canonical config with settings derived from Orca's runtime copy. One // read replaces the old existsSync + read pair and its TOCTOU gap. // The indeterminate arm is a backstop rather than the live guard: an - // unreadable system config already refused in readPromotedSettingValues, + // unreadable system config already refused above, // because `writeTarget.path` always resolves to the same file as // `systemTomlPath` (its realpath, its dangling-link target, or itself). const writeTargetObservation = observeAgentStateFile(writeTarget.path) @@ -170,7 +210,7 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( const systemContent = writeTargetObservation.kind === 'present' ? writeTargetObservation.value - : extractOrdinaryCodexSettings(runtimeTomlObservation.value) + : getMissingSystemSeedContent(runtimeTomlObservation.value, runtimeValues) const nextContent = upsertPromotedSettingsInContent(systemContent, updates) if (nextContent === systemContent) { return { conflicts, runtimeValuesToPreserve } @@ -200,6 +240,12 @@ function collectPromotionChanges(context: PromotionCollectionContext): void { ...context.baseline.settings.keys(), ...context.baseline.conflicts.keys() ])) { + if ( + isOrdinarySettingBlocked(context.runtimeValues, key) || + isOrdinarySettingBlocked(context.systemValues, key) + ) { + continue + } const runtimeRaw = getComparableRaw(context.runtimeValues.get(key)) const systemRaw = getComparableRaw(context.systemValues.get(key)) if (runtimeRaw === undefined || systemRaw === undefined) { @@ -207,10 +253,16 @@ function collectPromotionChanges(context: PromotionCollectionContext): void { } const existingConflict = context.baseline.conflicts.get(key) - if (existingConflict || !context.baseline.settings.has(key)) { - const resolution = resolveUntrackedCodexSetting(runtimeRaw, systemRaw, existingConflict) + const promoted = isPromotedStructuredKey(key) + const tracked = isTrackedSetting(context.baseline, key, promoted) + if (existingConflict || !tracked) { + const resolution = promoted + ? resolveUntrackedCodexSetting(runtimeRaw, systemRaw, existingConflict) + : resolveUnlistedCodexSetting(runtimeRaw, systemRaw, existingConflict) if (resolution.action === 'promote-runtime') { - context.updates.set(key, resolution.raw) + if (promoted) { + context.updates.set(key, resolution.raw) + } } else if (resolution.action === 'preserve') { // Why: a schema-new key has no three-way ancestor; preserve both values until content changes one side. context.conflicts.set(key, resolution.conflict) @@ -219,22 +271,68 @@ function collectPromotionChanges(context: PromotionCollectionContext): void { continue } - if (runtimeRaw === null || runtimeRaw === context.baseline.settings.get(key)) { + const ancestor = context.baseline.settings.get(key) ?? null + if (runtimeRaw === ancestor) { continue } - // Why: ~/.codex remains source of truth when both sides changed from a known baseline. - if (systemRaw !== context.baseline.settings.get(key)) { + if (systemRaw !== ancestor) { + if (!promoted && runtimeRaw !== systemRaw) { + preserveLocalValue(context, key, runtimeRaw, systemRaw) + } continue } - context.updates.set(key, runtimeRaw) + if (promoted) { + if (runtimeRaw !== null) { + context.updates.set(key, runtimeRaw) + } + } else { + preserveLocalValue(context, key, runtimeRaw, systemRaw) + } } } +function isTrackedSetting( + baseline: CodexSettingsBaseline, + key: string, + promoted: boolean +): boolean { + if (promoted && baseline.tracksAllOrdinarySettings && !baseline.knownPromotedKeys.has(key)) { + return false + } + return baseline.settings.has(key) || (baseline.tracksAllOrdinarySettings && !promoted) +} + +function preserveLocalValue( + context: PromotionCollectionContext, + key: string, + runtime: string | null, + system: string | null +): void { + context.conflicts.set(key, { runtime, system }) + context.runtimeValuesToPreserve.set(key, runtime) +} + +function getMissingSystemSeedContent( + runtimeContent: string, + runtimeValues: ReadonlyMap +): string { + const localOnlyKeys = new Set() + for (const [key, value] of runtimeValues) { + if (!isPromotedStructuredKey(key) && !value.multiline && !value.unsupported) { + localOnlyKeys.add(key) + } + } + return removePromotedSettingsFromContent( + extractOrdinaryCodexSettings(runtimeContent), + localOnlyKeys + ) +} + function getComparableRaw(value: OrdinaryCodexSettingValue | undefined): string | null | undefined { if (!value) { return null } - return value.multiline ? undefined : value.raw + return value.multiline || value.unsupported ? undefined : value.raw } function emptyPromotionPlan(): CodexSettingsPromotionPlan { diff --git a/src/main/codex/config-toml-ordinary-settings.ts b/src/main/codex/config-toml-ordinary-settings.ts index dd341f654f4..5444dfd85a6 100644 --- a/src/main/codex/config-toml-ordinary-settings.ts +++ b/src/main/codex/config-toml-ordinary-settings.ts @@ -12,6 +12,7 @@ export type OrdinaryCodexSettingValue = { raw: string // Why: a multiline string/array value can't be replaced line-by-line, so it's excluded from promotion. multiline: boolean + unsupported?: boolean } // Why: these are user preferences the Codex TUI persists. Pre-registering them @@ -46,6 +47,8 @@ export function parseOrdinaryCodexSettingValues( let inPreamble = true let tuiTableSeen = false let tuiBodyActive = false + let genericTuiBodyActive = false + const blockedKeys = new Set() for (const line of lines) { if (isTomlStructuralLine(state)) { const header = getTomlTableHeader(line) @@ -57,19 +60,29 @@ export function parseOrdinaryCodexSettingValues( table.segments.length === 1 && table.segments[0] === 'tui' && !tuiTableSeen + genericTuiBodyActive = tuiBodyActive && header.trim() === '[tui]' if (tuiBodyActive) { tuiTableSeen = true } + blockTableNamespace(table, blockedKeys) inPreamble = false state = updateTomlLineScanState(state, line) continue } - const matched = matchOrdinaryStructuredKey(line, inPreamble, tuiBodyActive) + blockDottedNamespace(line, inPreamble, tuiBodyActive, blockedKeys) + blockTuiScalarNamespace(line, inPreamble, blockedKeys) + const matched = matchOrdinaryStructuredKey( + line, + inPreamble, + tuiBodyActive, + genericTuiBodyActive + ) if (matched) { const nextState = updateTomlLineScanState(state, line) result.set(matched.structuredKey, { raw: matched.raw, - multiline: !isTomlStructuralLine(nextState) + multiline: !isTomlStructuralLine(nextState), + unsupported: containsInlineTable(matched.raw) }) state = nextState continue @@ -77,6 +90,9 @@ export function parseOrdinaryCodexSettingValues( } state = updateTomlLineScanState(state, line) } + for (const key of blockedKeys) { + result.set(key, { raw: '', multiline: false, unsupported: true }) + } return result } @@ -98,13 +114,15 @@ export function collectOrdinaryCodexSettingKeys( function matchOrdinaryStructuredKey( line: string, inPreamble: boolean, - tuiBodyActive: boolean + tuiBodyActive: boolean, + genericTuiBodyActive: boolean ): { structuredKey: string; raw: string } | null { const parsed = parseTomlKeyPath(line) if (!parsed || line[parsed.end] !== '=') { return null } const raw = line.slice(parsed.end + 1).trim() + const keySource = line.slice(0, parsed.end).trim() const topLevelKey = parsed.segments.length === 1 ? parsed.segments[0] : null // Why: `tui = { ... }` already defines the tui table; scanning it as a scalar // would strand a conflict against a `[tui]` body the upsert cannot place. @@ -112,23 +130,155 @@ function matchOrdinaryStructuredKey( inPreamble && topLevelKey && topLevelKey !== 'tui' && + (isPromotedStructuredKey(topLevelKey) || isBareTomlKey(keySource)) && !isOrcaRewrittenCodexPreambleKey(topLevelKey) ) { return { structuredKey: topLevelKey, raw } } - const tuiKey = matchTuiStructuredKey(parsed.segments, inPreamble, tuiBodyActive) + const tuiKey = matchTuiStructuredKey( + parsed.segments, + keySource, + inPreamble, + tuiBodyActive, + genericTuiBodyActive + ) return tuiKey ? { structuredKey: tuiKey, raw } : null } function matchTuiStructuredKey( keyPath: string[], + keySource: string, inPreamble: boolean, - tuiBodyActive: boolean + tuiBodyActive: boolean, + genericTuiBodyActive: boolean ): string | null { if (inPreamble) { const tuiKey = keyPath.length === 2 && keyPath[0] === 'tui' ? keyPath[1] : null - return tuiKey ? tuiStructuredKey(tuiKey) : null + if (!tuiKey) { + return null + } + const structuredKey = tuiStructuredKey(tuiKey) + return isPromotedStructuredKey(structuredKey) || isBareDottedTuiKey(keySource) + ? structuredKey + : null } const tuiKey = keyPath.length === 1 ? keyPath[0] : null - return tuiBodyActive && tuiKey ? tuiStructuredKey(tuiKey) : null + if (!tuiBodyActive || !tuiKey) { + return null + } + const structuredKey = tuiStructuredKey(tuiKey) + return isPromotedStructuredKey(structuredKey) || + (genericTuiBodyActive && isBareTomlKey(keySource)) + ? structuredKey + : null +} + +export function isPromotedStructuredKey(key: string): boolean { + return PROMOTED_STRUCTURED_KEYS.includes(key) +} + +export function isOrdinarySettingBlocked( + values: ReadonlyMap, + key: string +): boolean { + return ( + !!values.get(key)?.unsupported || (key.startsWith('tui.') && !!values.get('tui')?.unsupported) + ) +} + +function isBareTomlKey(source: string): boolean { + return /^[A-Za-z0-9_-]+$/.test(source) +} + +function isBareDottedTuiKey(source: string): boolean { + return /^tui\s*\.\s*[A-Za-z0-9_-]+$/.test(source) +} + +function blockTableNamespace( + table: ReturnType, + blockedKeys: Set +): void { + if (!table || table.segments.length === 0) { + return + } + if (table.segments[0] === 'tui') { + if (table.segments.length > 1) { + const key = table.segments[1] + if (key) { + blockedKeys.add(tuiStructuredKey(key)) + } + } + return + } + const root = table.segments[0] + if (root) { + blockedKeys.add(root) + } +} + +function blockDottedNamespace( + line: string, + inPreamble: boolean, + tuiBodyActive: boolean, + blockedKeys: Set +): void { + const parsed = parseTomlKeyPath(line) + if (!parsed || line[parsed.end] !== '=') { + return + } + if (inPreamble && parsed.segments[0] === 'tui' && parsed.segments.length > 2) { + const key = parsed.segments[1] + if (key) { + blockedKeys.add(tuiStructuredKey(key)) + } + } else if (inPreamble && parsed.segments.length > 1 && parsed.segments[0] !== 'tui') { + const root = parsed.segments[0] + if (root) { + blockedKeys.add(root) + } + } else if (tuiBodyActive && parsed.segments.length > 1) { + const key = parsed.segments[0] + if (key) { + blockedKeys.add(tuiStructuredKey(key)) + } + } +} + +function blockTuiScalarNamespace( + line: string, + inPreamble: boolean, + blockedKeys: Set +): void { + if (!inPreamble) { + return + } + const parsed = parseTomlKeyPath(line) + if (parsed?.segments.length === 1 && parsed.segments[0] === 'tui' && line[parsed.end] === '=') { + blockedKeys.add('tui') + } +} + +function containsInlineTable(raw: string): boolean { + let quote: '"' | "'" | null = null + for (let index = 0; index < raw.length; index += 1) { + const char = raw[index] + if (quote === '"' && char === '\\') { + index += 1 + continue + } + if (quote) { + if (char === quote) { + quote = null + } + continue + } + if (char === '"' || char === "'") { + quote = char + } else if (char === '#') { + return false + } else if (char === '{') { + return true + } + } + return false } diff --git a/src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts b/src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts index adab285b592..1b0711f85d3 100644 --- a/src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts +++ b/src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts @@ -368,7 +368,10 @@ describe('STA-4823 D26 — an unreadable settings baseline must stall the mirror // 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 }) + expect(JSON.parse(realFs.readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ + version: 3, + sourceAuthority: 'mirrored' + }) }) it('still replaces a fully-read baseline rejected by the JSON structure limit', () => { @@ -377,7 +380,10 @@ describe('STA-4823 D26 — an unreadable settings baseline must stall the mirror snapshotCodexRuntimeSettingsBaseline(runtimeHomePath) - expect(JSON.parse(realFs.readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 2 }) + expect(JSON.parse(realFs.readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ + version: 3, + sourceAuthority: 'mirrored' + }) }) it('rebuilds an oversized baseline previously produced from a bounded runtime config', () => { @@ -387,8 +393,13 @@ describe('STA-4823 D26 — an unreadable settings baseline must stall the mirror realFs.writeFileSync(join(runtimeHomePath, 'config.toml'), oversizedRuntimeConfig, 'utf-8') snapshotCodexRuntimeSettingsBaseline(runtimeHomePath) - expect(realFs.statSync(baselinePath()).size).toBeGreaterThan(MAX_AGENT_STATE_FILE_BYTES) - expect(observeCodexSettingsBaseline(runtimeHomePath)).toEqual({ kind: 'absent' }) + expect(realFs.statSync(baselinePath()).size).toBeLessThan(MAX_AGENT_STATE_FILE_BYTES) + expect(JSON.parse(realFs.readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ + version: 2, + sourceAuthority: 'mirrored', + settings: {} + }) + expect(observeCodexSettingsBaseline(runtimeHomePath).kind).toBe('present') realFs.writeFileSync(join(runtimeHomePath, 'config.toml'), 'model = "recovered"\n', 'utf-8') snapshotCodexRuntimeSettingsBaseline(runtimeHomePath)