Files
orca/src/main/codex/config-toml-runtime-owned-sections.ts
T
kin001andBrennan Benson 6a72c8f120 fix(codex): preserve runtime config when system source is missing (#9127)
* fix(codex): preserve runtime config without system source

* fix(codex): retain baseline when mirror is skipped

* refactor(codex): extract deprecated hook-flag normalization

Why: codex-config-mirror.ts sat at the 300-line cap, so the missing-source
guard could not land without a max-lines disable.

* fix(codex): bootstrap a baseline when the mirror is skipped

Why: a runtime home seeded outside the mirror (WSL, per-account) never got a
baseline while the source was missing, so promotion stayed inert and silently
reverted the in-Codex change once the source returned.

* fix(codex): stop a synthesized source config from wiping runtime settings

Two routes still reached the #9073 data loss after the missing-source guard:

- Promotion runs before the guard and, with no ~/.codex/config.toml, created
  one holding only the promoted keys. The next mirror treated that skeleton as
  authoritative and deleted every other runtime setting. It needs no missing
  file: `codex mcp add` inside an Orca-launched Codex plus /model was enough to
  drop the MCP server for good. Promotion now seeds a brand-new system config
  from the runtime's ordinary settings, so the mirror round-trips them.
- A 0-byte source (half-written, or an unhydrated cloud-synced home) still read
  as an authoritative empty config and advanced the baseline, making the loss
  unrecoverable. A blank source is now treated like a missing one.

Moves the TOML section model out of codex-config-mirror.ts so promotion can
share it without a cycle.

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-07-24 13:38:10 -07:00

165 lines
5.8 KiB
TypeScript

import {
createTomlLineScanState,
getTomlTableHeader,
isTomlStructuralLine,
updateTomlLineScanState
} from './config-toml-line-scan'
import {
normalizeCodexProjectPathForLookup,
normalizeCodexProjectPathForRevocationLookup,
parseCodexProjectHeaderPath
} from './config-toml-trust'
export type TomlSection = {
header: string
block: string
start: number
}
export function stripRuntimeOwnedTomlSections(
config: string,
runtimeProjectHeaders = new Set<string>()
): string {
const lines = config.split('\n')
const sourceSections = getTomlSections(config)
const sections = deduplicateProjectTomlSections(sourceSections)
const firstSectionIndex = sourceSections[0]?.start ?? -1
const preamble = firstSectionIndex === -1 ? config : lines.slice(0, firstSectionIndex).join('\n')
return joinTomlBlocks([
preamble,
...sections
.filter((section) => !isRuntimeHookTrustTomlSection(section.header))
.filter(
(section) =>
!isRuntimeProjectTomlSection(section.header) ||
!runtimeProjectHeaders.has(getTomlSectionHeaderKey(section.header)) ||
getProjectTrustLevel(section.block) === 'untrusted'
)
.map((section) => section.block)
])
}
export function getTomlSections(config: string): TomlSection[] {
const lines = config.split('\n')
const sections: TomlSection[] = []
let sectionStart = -1
let sectionHeader: string | null = null
let scanState = createTomlLineScanState()
for (let index = 0; index < lines.length; index += 1) {
const header = isTomlStructuralLine(scanState) ? getTomlTableHeader(lines[index] ?? '') : null
if (!header) {
scanState = updateTomlLineScanState(scanState, lines[index] ?? '')
continue
}
if (sectionStart !== -1) {
sections.push({
header: sectionHeader ?? '',
block: lines.slice(sectionStart, index).join('\n'),
start: sectionStart
})
}
sectionStart = index
sectionHeader = header
scanState = updateTomlLineScanState(scanState, lines[index] ?? '')
}
if (sectionStart !== -1) {
sections.push({
header: sectionHeader ?? '',
block: lines.slice(sectionStart).join('\n'),
start: sectionStart
})
}
return sections
}
export function isRuntimePreservedTomlSection(header: string): boolean {
return isRuntimeHookTrustTomlSection(header) || isRuntimeProjectTomlSection(header)
}
export function isRuntimeHookTrustTomlSection(header: string): boolean {
const trimmed = header.trim()
// Why: Codex's config writer materializes the parent table on Windows. It is
// part of runtime-owned trust and must survive the next config mirror too.
return trimmed === '[hooks.state]' || trimmed.startsWith('[hooks.state.')
}
export function isRuntimeProjectTomlSection(header: string): boolean {
return parseCodexProjectHeaderPath(header) !== null
}
export function getTomlSectionHeaderKey(header: string): string {
const projectPath = parseCodexProjectHeaderPath(header)
return projectPath === null
? header.trim()
: `project:${normalizeCodexProjectPathForLookup(projectPath)}`
}
// Why: configs written before WSL tails compared case-sensitively can hold a
// revocation under drifted casing; match it loosely so trust is not resurrected.
export function getRevocationTomlSectionHeaderKey(header: string): string {
const projectPath = parseCodexProjectHeaderPath(header)
return projectPath === null
? header.trim()
: `project:${normalizeCodexProjectPathForRevocationLookup(projectPath)}`
}
// Why: hook upsert already removes both quote representations, while its paired
// Windows slash variants are required for Codex 0.140 and must remain distinct.
export function deduplicateProjectTomlSections(sections: TomlSection[]): TomlSection[] {
const deduplicated: TomlSection[] = []
const projectIndexes = new Map<string, number>()
for (const section of sections) {
if (!isRuntimeProjectTomlSection(section.header)) {
deduplicated.push(section)
continue
}
const key = getTomlSectionHeaderKey(section.header)
const existingIndex = projectIndexes.get(key)
if (existingIndex === undefined) {
projectIndexes.set(key, deduplicated.length)
deduplicated.push(section)
continue
}
const existing = deduplicated[existingIndex]
if (
existing &&
getProjectTrustLevel(existing.block) !== 'untrusted' &&
getProjectTrustLevel(section.block) === 'untrusted'
) {
// Why: revocation must survive self-healing regardless of duplicate order.
deduplicated[existingIndex] = section
}
}
return deduplicated
}
export function getProjectTrustLevel(block: string): 'trusted' | 'untrusted' | null {
const match =
/^[ \t]*trust_level[ \t]*=[ \t]*(?:"(trusted|untrusted)"|'(trusted|untrusted)')[ \t\r]*(?:#.*)?$/m.exec(
block
)
const trustLevel = match?.[1] ?? match?.[2] ?? null
return trustLevel === 'trusted' || trustLevel === 'untrusted' ? trustLevel : null
}
export function joinTomlBlocks(blocks: string[]): string {
const normalizedBlocks = blocks.map((block) => block.trim()).filter((block) => block.length > 0)
return normalizedBlocks.length === 0 ? '' : `${normalizedBlocks.join('\n\n')}\n`
}
// Why: with no ~/.codex/config.toml the runtime config is the user's only
// config, so promotion seeds ~/.codex from it. Trust is runtime-owned and the
// mirror re-appends it, so drop every project and hook-trust table here.
export function extractOrdinaryCodexSettings(config: string): string {
const sections = deduplicateProjectTomlSections(getTomlSections(config))
const projectHeaders = new Set(
sections
.filter((section) => isRuntimeProjectTomlSection(section.header))
.map((section) => getTomlSectionHeaderKey(section.header))
)
return stripRuntimeOwnedTomlSections(config, projectHeaders).trimEnd()
}