mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 16:17:51 +00:00
* 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>
165 lines
5.8 KiB
TypeScript
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()
|
|
}
|