fix(codex): re-land TUI settings promotion with anchored baseline (#10213)
* Reapply "Preserve Codex [tui] settings across managed CODEX_HOME remirrors (#9475)" (#10085)
This reverts commit 09756dfaff.
* fix(codex): anchor settings baseline migration
This commit is contained in:
parent
d4387b17d9
commit
ef985ed800
|
|
@ -27,6 +27,9 @@
|
|||
"../src/main/codex/codex-app-server-session.ts",
|
||||
"../src/main/codex/codex-config-mirror.ts",
|
||||
"../src/main/codex/codex-config-path-reference-rewrite.ts",
|
||||
"../src/main/codex/codex-config-settings-preservation.ts",
|
||||
"../src/main/codex/codex-config-settings-removal.ts",
|
||||
"../src/main/codex/codex-config-settings-upsert.ts",
|
||||
"../src/main/codex/codex-home-paths.ts",
|
||||
"../src/main/codex/codex-host-retry-deadlines.ts",
|
||||
"../src/main/codex/codex-hook-identity.ts",
|
||||
|
|
@ -41,7 +44,10 @@
|
|||
"../src/main/codex/codex-user-hook-trust-rebase.ts",
|
||||
"../src/main/codex/codex-wsl-hook-install-plan.ts",
|
||||
"../src/main/codex/codex-wsl-reconciliation-generations.ts",
|
||||
"../src/main/codex/config-settings-baseline.ts",
|
||||
"../src/main/codex/config-settings-conflict-resolution.ts",
|
||||
"../src/main/codex/config-settings-promotion.ts",
|
||||
"../src/main/codex/config-toml-key-path.ts",
|
||||
"../src/main/codex/config-toml-line-scan.ts",
|
||||
"../src/main/codex/config-toml-trust.ts",
|
||||
"../src/main/codex/hook-service.ts",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ import { parseWslUncPath } from '../../shared/wsl-paths'
|
|||
import {
|
||||
promoteCodexRuntimeSettingsToSystem,
|
||||
snapshotCodexRuntimeSettingsBaseline,
|
||||
type CodexSettingsPromotionHomes
|
||||
type CodexSettingsPromotionHomes,
|
||||
type CodexSettingsPromotionPlan
|
||||
} from './config-settings-promotion'
|
||||
import { preserveRuntimeConflictValues } from './codex-config-settings-preservation'
|
||||
import {
|
||||
createTomlLineScanState,
|
||||
getTomlTableHeader,
|
||||
|
|
@ -31,32 +33,37 @@ export function syncSystemConfigIntoManagedCodexHome(
|
|||
// Why: the mirror overwrites runtime settings from ~/.codex, so changes the
|
||||
// user made inside Orca-launched Codex (/model, /approvals) must be written
|
||||
// back to ~/.codex first or this very pass silently reverts them.
|
||||
if (!promoteCodexRuntimeSettingsToSystem(homes)) {
|
||||
const promotionPlan = promoteCodexRuntimeSettingsToSystem(homes)
|
||||
if (!promotionPlan) {
|
||||
// Why: mirroring after a failed write-back would erase the runtime change;
|
||||
// leave both runtime and its old baseline intact so the next launch retries.
|
||||
return
|
||||
}
|
||||
let preservedConflictKeys: ReadonlySet<string>
|
||||
try {
|
||||
syncSystemConfigIntoManagedCodexHomeUnsafe(homes)
|
||||
preservedConflictKeys = syncSystemConfigIntoManagedCodexHomeUnsafe(homes, promotionPlan)
|
||||
} catch (error) {
|
||||
console.warn('[codex-config] Failed to mirror system Codex config:', error)
|
||||
return
|
||||
}
|
||||
// Why: the baseline advances only after a successful mirror; recording an
|
||||
// unpromoted runtime change as Orca-written would strand it forever.
|
||||
snapshotCodexRuntimeSettingsBaseline(homes.runtimeHomePath)
|
||||
snapshotCodexRuntimeSettingsBaseline(
|
||||
homes.runtimeHomePath,
|
||||
new Map([...promotionPlan.conflicts].filter(([key]) => preservedConflictKeys.has(key)))
|
||||
)
|
||||
}
|
||||
|
||||
function syncSystemConfigIntoManagedCodexHomeUnsafe({
|
||||
runtimeHomePath,
|
||||
systemHomePath
|
||||
}: CodexSettingsPromotionHomes): void {
|
||||
function syncSystemConfigIntoManagedCodexHomeUnsafe(
|
||||
{ runtimeHomePath, systemHomePath }: CodexSettingsPromotionHomes,
|
||||
promotionPlan: CodexSettingsPromotionPlan
|
||||
): ReadonlySet<string> {
|
||||
const systemConfigPath = join(systemHomePath, 'config.toml')
|
||||
const runtimeConfigPath = join(runtimeHomePath, 'config.toml')
|
||||
const systemConfigExists = existsSync(systemConfigPath)
|
||||
const runtimeConfigExists = existsSync(runtimeConfigPath)
|
||||
if (!systemConfigExists && !runtimeConfigExists) {
|
||||
return
|
||||
return new Set()
|
||||
}
|
||||
|
||||
const rawSystemConfig = systemConfigExists ? readAgentStateFileSync(systemConfigPath) : ''
|
||||
|
|
@ -66,15 +73,19 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe({
|
|||
runtimeConfigPath,
|
||||
prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir)
|
||||
)
|
||||
return
|
||||
return new Set()
|
||||
}
|
||||
|
||||
const systemConfig = prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir)
|
||||
const runtimeConfig = readAgentStateFileSync(runtimeConfigPath)
|
||||
const mergedConfig = mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig)
|
||||
if (mergedConfig !== runtimeConfig) {
|
||||
writeFileAtomically(runtimeConfigPath, mergedConfig)
|
||||
const preserved = preserveRuntimeConflictValues(
|
||||
mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig),
|
||||
promotionPlan.runtimeValuesToPreserve
|
||||
)
|
||||
if (preserved.content !== runtimeConfig) {
|
||||
writeFileAtomically(runtimeConfigPath, preserved.content)
|
||||
}
|
||||
return preserved.keys
|
||||
}
|
||||
|
||||
export function resolveCodexConfigMirrorSourceDirectory(systemHomePath: string): string {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import { removePromotedSettingsFromContent } from './codex-config-settings-removal'
|
||||
import { upsertPromotedSettingsInContent } from './codex-config-settings-upsert'
|
||||
|
||||
export function preserveRuntimeConflictValues(
|
||||
content: string,
|
||||
values: ReadonlyMap<string, string | null>
|
||||
): { content: string; keys: ReadonlySet<string> } {
|
||||
let result = content
|
||||
const keys = new Set<string>()
|
||||
for (const [key, raw] of values) {
|
||||
const previous = result
|
||||
result =
|
||||
raw === null
|
||||
? removePromotedSettingsFromContent(result, new Set([key]))
|
||||
: upsertPromotedSettingsInContent(result, new Map([[key, raw]]))
|
||||
if (result !== previous) {
|
||||
keys.add(key)
|
||||
}
|
||||
}
|
||||
// Why: only schema-new ambiguous keys stay runtime-local; every unrelated setting still mirrors.
|
||||
return { content: result, keys }
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { removePromotedSettingsFromContent } from './codex-config-settings-removal'
|
||||
|
||||
describe('removePromotedSettingsFromContent', () => {
|
||||
it('removes a top-level preamble key without touching nested copies', () => {
|
||||
expect(
|
||||
removePromotedSettingsFromContent(
|
||||
'model = "root"\n\n[profiles.dev]\nmodel = "nested"\n',
|
||||
new Set(['model'])
|
||||
)
|
||||
).toBe('\n[profiles.dev]\nmodel = "nested"\n')
|
||||
})
|
||||
|
||||
it('removes a bare key from the first tui table body', () => {
|
||||
expect(
|
||||
removePromotedSettingsFromContent(
|
||||
'[tui]\ntheme = "dark"\nanimations = true\n\n[tui.notifications]\ntheme = "nested"\n',
|
||||
new Set(['tui.theme'])
|
||||
)
|
||||
).toBe('[tui]\nanimations = true\n\n[tui.notifications]\ntheme = "nested"\n')
|
||||
})
|
||||
|
||||
it('removes dotted and quoted dotted tui keys from the preamble', () => {
|
||||
expect(
|
||||
removePromotedSettingsFromContent(
|
||||
'tui.theme = "dark"\n"tui" . "status_line" = ["model"]\n',
|
||||
new Set(['tui.theme', 'tui.status_line'])
|
||||
)
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
it('does not remove a tui-shaped key inside another table', () => {
|
||||
const content = '[[profiles]]\ntui.theme = "profile-theme"\n'
|
||||
expect(removePromotedSettingsFromContent(content, new Set(['tui.theme']))).toBe(content)
|
||||
})
|
||||
|
||||
it('does not remove a nested tui descendant with the same first segment', () => {
|
||||
const content = '[tui]\ntheme.variant = "dark"\n'
|
||||
expect(removePromotedSettingsFromContent(content, new Set(['tui.theme']))).toBe(content)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import {
|
||||
createTomlLineScanState,
|
||||
getTomlTableHeader,
|
||||
isTomlStructuralLine,
|
||||
updateTomlLineScanState
|
||||
} from './config-toml-line-scan'
|
||||
import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path'
|
||||
import { tuiStructuredKey } from './codex-config-settings-upsert'
|
||||
|
||||
export function removePromotedSettingsFromContent(
|
||||
content: string,
|
||||
removals: ReadonlySet<string>
|
||||
): string {
|
||||
if (removals.size === 0) {
|
||||
return content
|
||||
}
|
||||
const lines = content.split('\n')
|
||||
const indexes: number[] = []
|
||||
let state = createTomlLineScanState()
|
||||
let inPreamble = true
|
||||
let tuiTableSeen = false
|
||||
let tuiBodyActive = false
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? ''
|
||||
if (isTomlStructuralLine(state)) {
|
||||
const header = getTomlTableHeader(line)
|
||||
if (header) {
|
||||
const table = parseTomlTableHeaderPath(header)
|
||||
tuiBodyActive =
|
||||
table !== null &&
|
||||
!table.isArray &&
|
||||
table.segments.length === 1 &&
|
||||
table.segments[0] === 'tui' &&
|
||||
!tuiTableSeen
|
||||
tuiTableSeen ||= tuiBodyActive
|
||||
inPreamble = false
|
||||
state = updateTomlLineScanState(state, line)
|
||||
continue
|
||||
}
|
||||
const parsed = parseTomlKeyPath(line)
|
||||
if (parsed && line[parsed.end] === '=') {
|
||||
const structuredKey = getStructuredKey(parsed.segments, inPreamble, tuiBodyActive)
|
||||
if (structuredKey && removals.has(structuredKey)) {
|
||||
indexes.push(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
state = updateTomlLineScanState(state, line)
|
||||
}
|
||||
|
||||
for (const index of indexes.toReversed()) {
|
||||
lines.splice(index, 1)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function getStructuredKey(
|
||||
segments: string[],
|
||||
inPreamble: boolean,
|
||||
tuiBodyActive: boolean
|
||||
): string | null {
|
||||
if (inPreamble && segments.length === 1) {
|
||||
return segments[0] ?? null
|
||||
}
|
||||
if (inPreamble && segments.length === 2 && segments[0] === 'tui') {
|
||||
return segments[1] ? tuiStructuredKey(segments[1]) : null
|
||||
}
|
||||
if (tuiBodyActive && segments.length === 1) {
|
||||
return segments[0] ? tuiStructuredKey(segments[0]) : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
import {
|
||||
createTomlLineScanState,
|
||||
getTomlTableHeader,
|
||||
isTomlStructuralLine,
|
||||
updateTomlLineScanState
|
||||
} from './config-toml-line-scan'
|
||||
import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path'
|
||||
|
||||
const TUI_STRUCTURED_PREFIX = 'tui.'
|
||||
|
||||
// Why: promoted [tui] settings are keyed by structured path (tui.<key>) so their
|
||||
// baseline/update entries can never collide with a top-level key of the same name.
|
||||
export function tuiStructuredKey(key: string): string {
|
||||
return `${TUI_STRUCTURED_PREFIX}${key}`
|
||||
}
|
||||
|
||||
export function isTuiStructuredKey(structuredKey: string): boolean {
|
||||
return structuredKey.startsWith(TUI_STRUCTURED_PREFIX)
|
||||
}
|
||||
|
||||
export function tuiKeyFromStructuredKey(structuredKey: string): string {
|
||||
return structuredKey.slice(TUI_STRUCTURED_PREFIX.length)
|
||||
}
|
||||
|
||||
// Why: promoted updates arrive keyed by structured path; the preamble and [tui]
|
||||
// regions are disjoint, so a mixed batch (e.g. /model + a status-line change)
|
||||
// composes in one rewrite — top-level keys land in the preamble, tui.<key>
|
||||
// entries wherever the [tui] placement rule puts them.
|
||||
export function upsertPromotedSettingsInContent(
|
||||
content: string,
|
||||
updates: Map<string, string>
|
||||
): string {
|
||||
const topLevelUpdates = new Map<string, string>()
|
||||
const tuiUpdates = new Map<string, string>()
|
||||
for (const [key, raw] of updates) {
|
||||
if (isTuiStructuredKey(key)) {
|
||||
tuiUpdates.set(tuiKeyFromStructuredKey(key), raw)
|
||||
} else {
|
||||
topLevelUpdates.set(key, raw)
|
||||
}
|
||||
}
|
||||
let result = content
|
||||
if (topLevelUpdates.size > 0) {
|
||||
result = upsertTopLevelSettingsInContent(result, topLevelUpdates)
|
||||
}
|
||||
if (tuiUpdates.size > 0) {
|
||||
result = upsertTuiSettingsInContent(result, tuiUpdates)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function upsertTopLevelSettingsInContent(
|
||||
content: string,
|
||||
updates: Map<string, string>
|
||||
): string {
|
||||
const lines = content.split('\n')
|
||||
let state = createTomlLineScanState()
|
||||
let preambleEnd = lines.length
|
||||
const keyLineIndexes = new Map<string, number>()
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? ''
|
||||
if (isTomlStructuralLine(state)) {
|
||||
if (getTomlTableHeader(line)) {
|
||||
preambleEnd = index
|
||||
break
|
||||
}
|
||||
const parsed = parseTomlKeyPath(line)
|
||||
const key = parsed?.segments.length === 1 ? parsed.segments[0] : null
|
||||
if (parsed && line[parsed.end] === '=' && key && updates.has(key)) {
|
||||
keyLineIndexes.set(key, index)
|
||||
}
|
||||
}
|
||||
state = updateTomlLineScanState(state, line)
|
||||
}
|
||||
|
||||
// Why: CRLF configs keep a trailing \r after the split; new lines must use
|
||||
// the file's existing endings or a Windows-owned config becomes mixed-EOL.
|
||||
const usesCrlf = content.includes('\r\n')
|
||||
const insertions: string[] = []
|
||||
for (const [key, raw] of updates) {
|
||||
const existingIndex = keyLineIndexes.get(key)
|
||||
const rendered = `${key} = ${raw}`
|
||||
if (existingIndex !== undefined) {
|
||||
lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered
|
||||
} else {
|
||||
insertions.push(usesCrlf ? `${rendered}\r` : rendered)
|
||||
}
|
||||
}
|
||||
if (insertions.length > 0) {
|
||||
let insertAt = preambleEnd
|
||||
while (insertAt > 0 && (lines[insertAt - 1] ?? '').trim() === '') {
|
||||
insertAt -= 1
|
||||
}
|
||||
if (insertAt === preambleEnd && preambleEnd < lines.length) {
|
||||
insertions.push(usesCrlf ? '\r' : '')
|
||||
}
|
||||
lines.splice(insertAt, 0, ...insertions)
|
||||
}
|
||||
return joinPreservingTrailingNewline(lines, usesCrlf)
|
||||
}
|
||||
|
||||
type TuiPlacementScan = {
|
||||
bareKeyIndexes: Map<string, number>
|
||||
dottedKeyIndexes: Map<string, number>
|
||||
hasBareTuiTable: boolean
|
||||
hasDottedTuiKey: boolean
|
||||
blocksNewTuiTable: boolean
|
||||
blockedAbsentKeys: Set<string>
|
||||
bareBodyInsertIndex: number
|
||||
lastDottedTuiIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Upserts promoted `[tui]` keys (keyed by bare name) into the system config,
|
||||
* placing each per the design's total placement rule: replace an existing key
|
||||
* in place keeping its form; else insert bare into the first `[tui]` body; else
|
||||
* dotted in the preamble beside existing dotted `tui.*` keys; else create one
|
||||
* `[tui]` table at EOF for every key that reaches that branch. Rendering follows
|
||||
* the destination — bare inside a table, dotted in the preamble — so no `tui`
|
||||
* table is ever defined twice.
|
||||
*/
|
||||
export function upsertTuiSettingsInContent(content: string, updates: Map<string, string>): string {
|
||||
const lines = content.split('\n')
|
||||
const scan = scanTuiPlacement(lines, updates)
|
||||
const usesCrlf = content.includes('\r\n')
|
||||
const bareBodyInserts: string[] = []
|
||||
const dottedPreambleInserts: string[] = []
|
||||
const newTableKeys: string[] = []
|
||||
|
||||
for (const [key, raw] of updates) {
|
||||
const dottedIndex = scan.dottedKeyIndexes.get(key)
|
||||
if (dottedIndex !== undefined) {
|
||||
lines[dottedIndex] = withTrailingCr(lines[dottedIndex]!, `${tuiStructuredKey(key)} = ${raw}`)
|
||||
continue
|
||||
}
|
||||
const bareIndex = scan.bareKeyIndexes.get(key)
|
||||
if (bareIndex !== undefined) {
|
||||
lines[bareIndex] = withTrailingCr(lines[bareIndex]!, `${key} = ${raw}`)
|
||||
continue
|
||||
}
|
||||
// Why: adding a scalar beside an existing tui.<key> descendant would turn valid TOML invalid.
|
||||
if (scan.blockedAbsentKeys.has(key)) {
|
||||
continue
|
||||
}
|
||||
if (scan.hasBareTuiTable) {
|
||||
bareBodyInserts.push(`${key} = ${raw}`)
|
||||
} else if (scan.hasDottedTuiKey) {
|
||||
dottedPreambleInserts.push(`${tuiStructuredKey(key)} = ${raw}`)
|
||||
} else if (!scan.blocksNewTuiTable) {
|
||||
// Why: inline/array tui definitions block this branch because adding a
|
||||
// plain [tui] beside either would make the config invalid.
|
||||
newTableKeys.push(`${key} = ${raw}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the config shape routes every absent key to the same branch, so at most
|
||||
// one insert group is non-empty; still apply EOF→body→preamble so a splice
|
||||
// never shifts a lower index a later splice depends on.
|
||||
if (newTableKeys.length > 0) {
|
||||
appendNewTuiTable(lines, newTableKeys, usesCrlf)
|
||||
}
|
||||
if (bareBodyInserts.length > 0) {
|
||||
lines.splice(
|
||||
scan.bareBodyInsertIndex,
|
||||
0,
|
||||
...bareBodyInserts.map((line) => withCrLine(line, usesCrlf))
|
||||
)
|
||||
}
|
||||
if (dottedPreambleInserts.length > 0) {
|
||||
lines.splice(
|
||||
scan.lastDottedTuiIndex + 1,
|
||||
0,
|
||||
...dottedPreambleInserts.map((line) => withCrLine(line, usesCrlf))
|
||||
)
|
||||
}
|
||||
return joinPreservingTrailingNewline(lines, usesCrlf)
|
||||
}
|
||||
|
||||
function scanTuiPlacement(lines: string[], updates: Map<string, string>): TuiPlacementScan {
|
||||
let state = createTomlLineScanState()
|
||||
let inPreamble = true
|
||||
let tuiTableSeen = false
|
||||
let tuiBodyActive = false
|
||||
let tuiBodyHeaderIndex = -1
|
||||
let tuiBodyEndIndex = -1
|
||||
let hasDottedTuiKey = false
|
||||
let blocksNewTuiTable = false
|
||||
let lastDottedTuiIndex = -1
|
||||
const bareKeyIndexes = new Map<string, number>()
|
||||
const dottedKeyIndexes = new Map<string, number>()
|
||||
const blockedAbsentKeys = new Set<string>()
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? ''
|
||||
if (isTomlStructuralLine(state)) {
|
||||
const header = getTomlTableHeader(line)
|
||||
if (header) {
|
||||
if (tuiBodyActive) {
|
||||
tuiBodyEndIndex = index
|
||||
tuiBodyActive = false
|
||||
}
|
||||
const table = parseTomlTableHeaderPath(header)
|
||||
if (
|
||||
table &&
|
||||
!table.isArray &&
|
||||
table.segments.length === 1 &&
|
||||
table.segments[0] === 'tui' &&
|
||||
!tuiTableSeen
|
||||
) {
|
||||
tuiTableSeen = true
|
||||
tuiBodyActive = true
|
||||
tuiBodyHeaderIndex = index
|
||||
}
|
||||
// Why: a root [[tui]] is already an array, so appending [tui] would
|
||||
// redefine it and make an otherwise valid config unparseable.
|
||||
if (table?.isArray && table.segments.length === 1 && table.segments[0] === 'tui') {
|
||||
blocksNewTuiTable = true
|
||||
}
|
||||
const descendantKey =
|
||||
table?.segments[0] === 'tui' && table.segments.length > 1 ? table.segments[1] : null
|
||||
if (descendantKey && updates.has(descendantKey)) {
|
||||
blockedAbsentKeys.add(descendantKey)
|
||||
}
|
||||
inPreamble = false
|
||||
state = updateTomlLineScanState(state, line)
|
||||
continue
|
||||
}
|
||||
if (inPreamble) {
|
||||
// Why: any dotted `tui.*` key (allowlisted or not) already defines the
|
||||
// implicit tui table, so a new `[tui]` table at EOF would duplicate it.
|
||||
const parsed = parseTomlKeyPath(line)
|
||||
const isAssignment = parsed && line[parsed.end] === '='
|
||||
if (isAssignment && parsed.segments[0] === 'tui' && parsed.segments.length > 1) {
|
||||
hasDottedTuiKey = true
|
||||
lastDottedTuiIndex = index
|
||||
const promotedKey = parsed.segments.length === 2 ? parsed.segments[1] : null
|
||||
if (promotedKey && updates.has(promotedKey)) {
|
||||
dottedKeyIndexes.set(promotedKey, index)
|
||||
}
|
||||
const descendantKey = parsed.segments.length > 2 ? parsed.segments[1] : null
|
||||
if (descendantKey && updates.has(descendantKey)) {
|
||||
blockedAbsentKeys.add(descendantKey)
|
||||
}
|
||||
} else if (isAssignment && parsed.segments.length === 1 && parsed.segments[0] === 'tui') {
|
||||
blocksNewTuiTable = true
|
||||
}
|
||||
} else if (tuiBodyActive) {
|
||||
const parsed = parseTomlKeyPath(line)
|
||||
const key = parsed?.segments.length === 1 ? parsed.segments[0] : null
|
||||
if (parsed && line[parsed.end] === '=' && key && updates.has(key)) {
|
||||
bareKeyIndexes.set(key, index)
|
||||
}
|
||||
const descendantKey = parsed && parsed.segments.length > 1 ? parsed.segments[0] : null
|
||||
if (descendantKey && updates.has(descendantKey)) {
|
||||
blockedAbsentKeys.add(descendantKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
state = updateTomlLineScanState(state, line)
|
||||
}
|
||||
if (tuiBodyActive) {
|
||||
tuiBodyEndIndex = lines.length
|
||||
}
|
||||
|
||||
return {
|
||||
bareKeyIndexes,
|
||||
dottedKeyIndexes,
|
||||
hasBareTuiTable: tuiTableSeen,
|
||||
hasDottedTuiKey,
|
||||
blocksNewTuiTable,
|
||||
blockedAbsentKeys,
|
||||
bareBodyInsertIndex: computeBareBodyInsertIndex(lines, tuiBodyHeaderIndex, tuiBodyEndIndex),
|
||||
lastDottedTuiIndex
|
||||
}
|
||||
}
|
||||
|
||||
// Why: TOML forbids adding bare keys to `[tui]` after a `[tui.*]` subtable opens,
|
||||
// so absent keys land at the body's end — before trailing blanks and before the
|
||||
// next header — which is the only valid spot.
|
||||
function computeBareBodyInsertIndex(
|
||||
lines: string[],
|
||||
headerIndex: number,
|
||||
endIndex: number
|
||||
): number {
|
||||
if (headerIndex === -1) {
|
||||
return -1
|
||||
}
|
||||
let insertAt = endIndex
|
||||
while (insertAt > headerIndex + 1 && (lines[insertAt - 1] ?? '').trim() === '') {
|
||||
insertAt -= 1
|
||||
}
|
||||
return insertAt
|
||||
}
|
||||
|
||||
function appendNewTuiTable(lines: string[], keyRenders: string[], usesCrlf: boolean): void {
|
||||
let appendAt = lines.length
|
||||
while (appendAt > 0 && (lines[appendAt - 1] ?? '').trim() === '') {
|
||||
appendAt -= 1
|
||||
}
|
||||
// Why: separate the new table from prior content with a blank line, unless the
|
||||
// file was empty/blank, where a leading blank would be spurious.
|
||||
const block = appendAt > 0 ? ['', '[tui]', ...keyRenders] : ['[tui]', ...keyRenders]
|
||||
lines.splice(appendAt, 0, ...block.map((line) => withCrLine(line, usesCrlf)))
|
||||
}
|
||||
|
||||
function withTrailingCr(originalLine: string, rendered: string): string {
|
||||
return originalLine.endsWith('\r') ? `${rendered}\r` : rendered
|
||||
}
|
||||
|
||||
function withCrLine(rendered: string, usesCrlf: boolean): string {
|
||||
return usesCrlf ? `${rendered}\r` : rendered
|
||||
}
|
||||
|
||||
// Why: a missing trailing newline is restored in the file's own EOL so a
|
||||
// preamble-only or table-appended rewrite matches the source's newline behavior.
|
||||
function joinPreservingTrailingNewline(lines: string[], usesCrlf: boolean): string {
|
||||
const result = lines.join('\n')
|
||||
if (result.endsWith('\n') || result.length === 0) {
|
||||
return result
|
||||
}
|
||||
return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}`
|
||||
}
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import type * as Os from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const { homedirMock } = vi.hoisted(() => ({
|
||||
homedirMock: vi.fn<() => string>()
|
||||
}))
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof Os>()
|
||||
return { ...actual, homedir: homedirMock }
|
||||
})
|
||||
|
||||
import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror'
|
||||
|
||||
let tmpHome: string
|
||||
let userDataDir: string
|
||||
let previousUserDataPath: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-settings-upgrade-home-'))
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-settings-upgrade-data-'))
|
||||
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
process.env.ORCA_USER_DATA_PATH = userDataDir
|
||||
homedirMock.mockReturnValue(tmpHome)
|
||||
if (homedir() !== tmpHome) {
|
||||
throw new Error('node:os homedir mock is not active; refusing to touch the real ~/.codex')
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpHome, { recursive: true, force: true })
|
||||
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()
|
||||
})
|
||||
|
||||
function systemConfigPath(): string {
|
||||
return join(tmpHome, '.codex', 'config.toml')
|
||||
}
|
||||
|
||||
function runtimeHomePath(): string {
|
||||
return join(userDataDir, 'codex-runtime-home', 'home')
|
||||
}
|
||||
|
||||
function runtimeConfigPath(): string {
|
||||
return join(runtimeHomePath(), 'config.toml')
|
||||
}
|
||||
|
||||
function baselinePath(): string {
|
||||
return join(runtimeHomePath(), '.orca-config-settings-baseline.json')
|
||||
}
|
||||
|
||||
function prepareLegacyState(systemConfig: string, runtimeConfig: string): void {
|
||||
mkdirSync(join(tmpHome, '.codex'), { recursive: true })
|
||||
mkdirSync(runtimeHomePath(), { recursive: true })
|
||||
writeFileSync(systemConfigPath(), systemConfig, 'utf-8')
|
||||
writeFileSync(runtimeConfigPath(), runtimeConfig, 'utf-8')
|
||||
writeFileSync(
|
||||
baselinePath(),
|
||||
`${JSON.stringify({ version: 1, settings: { model: '"gpt-5"' } }, null, 2)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
}
|
||||
|
||||
function readBaseline(): {
|
||||
version: number
|
||||
settings: Record<string, string | null>
|
||||
conflicts?: Record<string, { runtime: string | null; system: string | null }>
|
||||
} {
|
||||
return JSON.parse(readFileSync(baselinePath(), 'utf-8'))
|
||||
}
|
||||
|
||||
describe('Codex settings baseline schema upgrade', () => {
|
||||
it('upgrades an aligned legacy baseline without creating a conflict', () => {
|
||||
const config = 'model = "gpt-5"\n\n[tui]\ntheme = "dark"\n'
|
||||
prepareLegacyState(config, config)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readBaseline()).toMatchObject({
|
||||
version: 2,
|
||||
settings: { model: '"gpt-5"', 'tui.theme': '"dark"' }
|
||||
})
|
||||
expect(readBaseline().conflicts).toBeUndefined()
|
||||
})
|
||||
|
||||
it('anchors a schema-new conflict while promoting an unrelated known key', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
|
||||
'model = "o4"\n\n[tui]\ntheme = "runtime"\n'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('model = "o4"')
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('theme = "system"')
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('theme = "runtime"')
|
||||
expect(readBaseline().conflicts).toEqual({
|
||||
'tui.theme': { runtime: '"runtime"', system: '"system"' }
|
||||
})
|
||||
})
|
||||
|
||||
it('promotes the runtime side when its anchored value changes', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
writeFileSync(
|
||||
runtimeConfigPath(),
|
||||
readFileSync(runtimeConfigPath(), 'utf-8').replace('theme = "runtime"', 'theme = "chosen"'),
|
||||
'utf-8'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('theme = "chosen"')
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('theme = "chosen"')
|
||||
expect(readBaseline().conflicts).toBeUndefined()
|
||||
expect(readBaseline().settings['tui.theme']).toBe('"chosen"')
|
||||
})
|
||||
|
||||
it('accepts the system side when its anchored value changes', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
writeFileSync(
|
||||
systemConfigPath(),
|
||||
readFileSync(systemConfigPath(), 'utf-8').replace('theme = "system"', 'theme = "outside"'),
|
||||
'utf-8'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('theme = "outside"')
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('theme = "outside"')
|
||||
expect(readBaseline().conflicts).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores unrelated config writes while a value pair is anchored', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
writeFileSync(
|
||||
runtimeConfigPath(),
|
||||
`${readFileSync(runtimeConfigPath(), 'utf-8')}\n[projects."/tmp/repo"]\ntrust_level = "trusted"\n`,
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
systemConfigPath(),
|
||||
`${readFileSync(systemConfigPath(), 'utf-8')}\n[features]\nhooks = true\n`,
|
||||
'utf-8'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('theme = "system"')
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('theme = "runtime"')
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('[projects."/tmp/repo"]')
|
||||
expect(readBaseline().conflicts).toEqual({
|
||||
'tui.theme': { runtime: '"runtime"', system: '"system"' }
|
||||
})
|
||||
})
|
||||
|
||||
it('re-anchors two new divergent values until one side changes again', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
writeFileSync(
|
||||
runtimeConfigPath(),
|
||||
readFileSync(runtimeConfigPath(), 'utf-8').replace(
|
||||
'theme = "runtime"',
|
||||
'theme = "runtime-2"'
|
||||
),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
systemConfigPath(),
|
||||
readFileSync(systemConfigPath(), 'utf-8').replace('theme = "system"', 'theme = "system-2"'),
|
||||
'utf-8'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readBaseline().conflicts).toEqual({
|
||||
'tui.theme': { runtime: '"runtime-2"', system: '"system-2"' }
|
||||
})
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('theme = "runtime-2"')
|
||||
})
|
||||
|
||||
it('preserves an absent runtime value until the user chooses one', () => {
|
||||
prepareLegacyState('model = "gpt-5"\n\n[tui]\ntheme = "system"\n', 'model = "gpt-5"\n')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).not.toContain('theme =')
|
||||
expect(readBaseline().conflicts).toEqual({
|
||||
'tui.theme': { runtime: null, system: '"system"' }
|
||||
})
|
||||
|
||||
writeFileSync(runtimeConfigPath(), 'model = "gpt-5"\n\n[tui]\ntheme = "chosen"\n', 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('theme = "chosen"')
|
||||
expect(readBaseline().conflicts).toBeUndefined()
|
||||
})
|
||||
|
||||
it('applies the migration rule to future top-level schema additions', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\napproval_policy = "never"\n',
|
||||
'model = "gpt-5"\napproval_policy = "on-request"\n'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('approval_policy = "never"')
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('approval_policy = "on-request"')
|
||||
expect(readBaseline().conflicts).toEqual({
|
||||
approval_policy: { runtime: '"on-request"', system: '"never"' }
|
||||
})
|
||||
})
|
||||
|
||||
it('lets an incompatible system TOML shape win instead of stranding a conflict', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\ntui = { animations = false }\n',
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('tui = { animations = false }')
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).not.toContain('theme = "runtime"')
|
||||
expect(readBaseline().conflicts).toBeUndefined()
|
||||
expect(readBaseline().settings['tui.theme']).toBeNull()
|
||||
})
|
||||
|
||||
it('does not require filesystem timestamp mutation during migration', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
|
||||
)
|
||||
const baselineBefore = readFileSync(baselinePath(), 'utf-8')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(existsSync(baselinePath())).toBe(true)
|
||||
expect(readFileSync(baselinePath(), 'utf-8')).not.toBe(baselineBefore)
|
||||
expect(readBaseline().conflicts?.['tui.theme']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { existsSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { readAgentStateFileSync, readAgentStateJsonFileSync } from '../agent-state-file-reader'
|
||||
|
||||
const SETTINGS_BASELINE_FILE = '.orca-config-settings-baseline.json'
|
||||
|
||||
export type CodexSettingsConflict = {
|
||||
runtime: string | null
|
||||
system: string | null
|
||||
}
|
||||
|
||||
export type CodexSettingsBaseline = {
|
||||
settings: ReadonlyMap<string, string | null>
|
||||
conflicts: ReadonlyMap<string, CodexSettingsConflict>
|
||||
}
|
||||
|
||||
type StoredSettingsBaseline = {
|
||||
version: 1 | 2
|
||||
settings: Record<string, string | null>
|
||||
conflicts?: Record<string, CodexSettingsConflict>
|
||||
}
|
||||
|
||||
export function readCodexSettingsBaseline(runtimeHomePath: string): CodexSettingsBaseline | null {
|
||||
const baselinePath = getCodexSettingsBaselinePath(runtimeHomePath)
|
||||
if (!existsSync(baselinePath)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = readAgentStateJsonFileSync(baselinePath)
|
||||
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
|
||||
})
|
||||
)
|
||||
const conflicts = new Map<string, CodexSettingsConflict>()
|
||||
for (const [key, conflict] of Object.entries(parsed.conflicts ?? {})) {
|
||||
if (
|
||||
conflict &&
|
||||
(typeof conflict.runtime === 'string' || conflict.runtime === null) &&
|
||||
(typeof conflict.system === 'string' || conflict.system === null)
|
||||
) {
|
||||
conflicts.set(key, conflict)
|
||||
}
|
||||
}
|
||||
return { settings, conflicts }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
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 baselinePath = getCodexSettingsBaselinePath(runtimeHomePath)
|
||||
const serialized = `${JSON.stringify(file, null, 2)}\n`
|
||||
// Why: launch prep runs repeatedly; byte-identical baselines should not churn disk metadata.
|
||||
if (existsSync(baselinePath) && readAgentStateFileSync(baselinePath) === serialized) {
|
||||
return
|
||||
}
|
||||
writeFileSync(baselinePath, serialized, { encoding: 'utf-8', mode: 0o600 })
|
||||
}
|
||||
|
||||
function getCodexSettingsBaselinePath(runtimeHomePath: string): string {
|
||||
return join(runtimeHomePath, SETTINGS_BASELINE_FILE)
|
||||
}
|
||||
|
||||
function isStoredSettingsBaseline(value: unknown): value is StoredSettingsBaseline {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
const candidate = value as Partial<StoredSettingsBaseline>
|
||||
return (
|
||||
(candidate.version === 1 || candidate.version === 2) &&
|
||||
!!candidate.settings &&
|
||||
typeof candidate.settings === 'object' &&
|
||||
!Array.isArray(candidate.settings)
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import type { CodexSettingsConflict } from './config-settings-baseline'
|
||||
|
||||
export type CodexSettingsConflictResolution =
|
||||
| { action: 'aligned' }
|
||||
| { action: 'preserve'; conflict: CodexSettingsConflict }
|
||||
| { action: 'promote-runtime'; raw: string }
|
||||
| { action: 'use-system' }
|
||||
|
||||
export function resolveUntrackedCodexSetting(
|
||||
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) {
|
||||
// Why: steady-state promotion intentionally does not propagate deletions.
|
||||
return runtime === null ? { action: 'use-system' } : { action: 'promote-runtime', raw: runtime }
|
||||
}
|
||||
if (!runtimeChanged && systemChanged) {
|
||||
return { action: 'use-system' }
|
||||
}
|
||||
if (runtimeChanged && systemChanged) {
|
||||
// Why: two new divergent values remain ambiguous; re-anchor their content without blocking other keys.
|
||||
return { action: 'preserve', conflict: { runtime, system } }
|
||||
}
|
||||
return { action: 'preserve', conflict: existingConflict }
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ import type * as CodexFsUtils from '../codex-accounts/fs-utils'
|
|||
|
||||
const { homedirMock, promotionTestState } = vi.hoisted(() => ({
|
||||
homedirMock: vi.fn<() => string>(),
|
||||
promotionTestState: { failAtomicWrite: false }
|
||||
promotionTestState: { failAtomicWrite: false, atomicWritePaths: [] as string[] }
|
||||
}))
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
|
|
@ -35,6 +35,7 @@ vi.mock('../codex-accounts/fs-utils', async (importOriginal) => {
|
|||
return {
|
||||
...actual,
|
||||
writeFileAtomically: (...args: Parameters<typeof actual.writeFileAtomically>) => {
|
||||
promotionTestState.atomicWritePaths.push(args[0])
|
||||
if (promotionTestState.failAtomicWrite) {
|
||||
throw new Error('injected atomic write failure')
|
||||
}
|
||||
|
|
@ -44,7 +45,15 @@ vi.mock('../codex-accounts/fs-utils', async (importOriginal) => {
|
|||
})
|
||||
|
||||
import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror'
|
||||
import { upsertTopLevelSettingsInContent } from './config-settings-promotion'
|
||||
import {
|
||||
upsertPromotedSettingsInContent,
|
||||
upsertTopLevelSettingsInContent
|
||||
} from './codex-config-settings-upsert'
|
||||
|
||||
// The exact [tui] block codex 0.144.6 writes via config/batchWrite (all four
|
||||
// promoted keys single-line, theme a string).
|
||||
const CODEX_TUI_BLOCK =
|
||||
'[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\nstatus_line_use_colors = true\nterminal_title = ["model"]\ntheme = "dark-photon"\n'
|
||||
|
||||
let tmpHome: string
|
||||
let userDataDir: string
|
||||
|
|
@ -57,6 +66,7 @@ beforeEach(() => {
|
|||
process.env.ORCA_USER_DATA_PATH = userDataDir
|
||||
homedirMock.mockReturnValue(tmpHome)
|
||||
promotionTestState.failAtomicWrite = false
|
||||
promotionTestState.atomicWritePaths.length = 0
|
||||
// Why: promotion writes into homedir()/.codex — if the mock ever fails to
|
||||
// intercept, these tests would rewrite the developer's real Codex config.
|
||||
if (homedir() !== tmpHome) {
|
||||
|
|
@ -117,6 +127,13 @@ function simulateCodexSettingWrite(key: string, rawValue: string): void {
|
|||
writeFileSync(runtimeConfigPath(), next, 'utf-8')
|
||||
}
|
||||
|
||||
// Codex reads then rewrites the whole runtime config; simulate that by writing
|
||||
// a known runtime config directly (its EOL is normalized by the mirror anyway).
|
||||
function setRuntimeConfig(content: string): void {
|
||||
mkdirSync(runtimeHomeDir(), { recursive: true })
|
||||
writeFileSync(runtimeConfigPath(), content, 'utf-8')
|
||||
}
|
||||
|
||||
function simulateCodexSettingRemoval(key: string): void {
|
||||
const existing = readFileSync(runtimeConfigPath(), 'utf-8')
|
||||
const linePattern = new RegExp(`^${key}[ \\t]*=.*\\n?`, 'm')
|
||||
|
|
@ -184,7 +201,7 @@ 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: 1 })
|
||||
expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 2 })
|
||||
|
||||
simulateCodexSettingWrite('model', '"o4"')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
|
@ -402,6 +419,363 @@ describe('codex settings write-back promotion', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('codex [tui] settings write-back promotion', () => {
|
||||
it('promotes a runtime [tui] block (codex 0.144.6 shape) into ~/.codex and survives the remirror', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
// The user customizes the status line/theme inside Orca-launched Codex.
|
||||
writeFileSync(runtimeConfigPath(), `${readRuntimeConfig()}\n${CODEX_TUI_BLOCK}`, 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(`model = "gpt-5"\n\n${CODEX_TUI_BLOCK}`)
|
||||
const runtime = readRuntimeConfig()
|
||||
expect(runtime).toContain('status_line = ["model-with-reasoning", "task-progress"]')
|
||||
expect(runtime).toContain('status_line_use_colors = true')
|
||||
expect(runtime).toContain('terminal_title = ["model"]')
|
||||
expect(runtime).toContain('theme = "dark-photon"')
|
||||
|
||||
const settledSystem = readSystemConfig()
|
||||
const settledRuntime = readRuntimeConfig()
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
expect(readSystemConfig()).toBe(settledSystem)
|
||||
expect(readRuntimeConfig()).toBe(settledRuntime)
|
||||
})
|
||||
|
||||
it('replaces a promoted key in an existing [tui] table, leaving non-promoted neighbors untouched', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "dark"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "light"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(
|
||||
'model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "light"\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('promotes a changed status_line array value', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n[tui]\nstatus_line = ["model"]\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig(
|
||||
'model = "gpt-5"\n\n[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\n'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(
|
||||
'model = "gpt-5"\n\n[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('promotes a model change and a status-line change in one pass into their regions', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "o4"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(
|
||||
'model = "o4"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('detects and replaces a dotted-form system tui key without creating a [tui] table', () => {
|
||||
writeSystemConfig('model = "gpt-5"\ntui.theme = "dark"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
// toml_edit preserves the dotted form when codex rewrites the value.
|
||||
setRuntimeConfig('model = "gpt-5"\ntui.theme = "light"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\ntui.theme = "light"\n')
|
||||
expect(readSystemConfig()).not.toContain('[tui]')
|
||||
})
|
||||
|
||||
it('promotes through a quoted tui table without creating a duplicate table', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n["tui"]\ntheme = "dark"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\n\n["tui"]\ntheme = "light"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\n\n["tui"]\ntheme = "light"\n')
|
||||
expect(readSystemConfig()).not.toContain('\n[tui]\n')
|
||||
})
|
||||
|
||||
it('inserts a second dotted tui key beside an existing dotted-only tui config', () => {
|
||||
writeSystemConfig('model = "gpt-5"\ntui.theme = "dark"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\ntui.theme = "dark"\ntui.status_line = ["model"]\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(
|
||||
'model = "gpt-5"\ntui.theme = "dark"\ntui.status_line = ["model"]\n'
|
||||
)
|
||||
expect(readSystemConfig()).not.toContain('[tui]')
|
||||
})
|
||||
|
||||
it('inserts dotted beside a non-promoted dotted tui key instead of creating a [tui] table', () => {
|
||||
// Why: any dotted tui.* key already defines the implicit tui table, so a
|
||||
// fresh [tui] table at EOF would be a duplicate-definition parse error.
|
||||
writeSystemConfig('model = "gpt-5"\ntui.pet = "cat"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\ntui.pet = "cat"\ntui.theme = "dark-photon"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\ntui.pet = "cat"\ntui.theme = "dark-photon"\n')
|
||||
expect(readSystemConfig()).not.toContain('[tui]')
|
||||
})
|
||||
|
||||
it('creates a [tui] table at EOF when the only tui presence is a subtable', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig(
|
||||
'model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n\n[tui]\nstatus_line = ["model"]\n'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(
|
||||
'model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n\n[tui]\nstatus_line = ["model"]\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('creates exactly one [tui] table for two keys promoted in one pass', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const system = readSystemConfig()
|
||||
expect(system.match(/^\[tui\]$/gm)?.length).toBe(1)
|
||||
expect(system).toBe(
|
||||
'model = "gpt-5"\n\n[tui]\nstatus_line = ["model"]\ntheme = "dark-photon"\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('lets an outside ~/.codex [tui] edit win over a conflicting in-Codex tui change', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "in-codex"\n')
|
||||
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "outside-edit"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\n\n[tui]\ntheme = "outside-edit"\n')
|
||||
expect(readRuntimeConfig()).toContain('theme = "outside-edit"')
|
||||
})
|
||||
|
||||
it('does not promote a [tui] key deletion', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\n\n[tui]\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toContain('theme = "dark"')
|
||||
})
|
||||
|
||||
it('inserts a promoted key into a CRLF system [tui] table preserving CRLF', () => {
|
||||
writeSystemConfig('model = "gpt-5"\r\n\r\n[tui]\r\ntheme = "dark"\r\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\nstatus_line = ["model"]\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const system = readSystemConfig()
|
||||
expect(system).toContain('status_line = ["model"]\r\n')
|
||||
expect(system).toBe(
|
||||
'model = "gpt-5"\r\n\r\n[tui]\r\ntheme = "dark"\r\nstatus_line = ["model"]\r\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('never appends a [tui] table when the system config defines tui inline', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
// In-Codex tui change racing an outside edit that adds an inline tui table:
|
||||
// appending [tui] would make the system config unparseable, so the change
|
||||
// is dropped instead.
|
||||
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\n')
|
||||
writeSystemConfig('model = "gpt-5"\ntui = { animations = false }\n')
|
||||
promotionTestState.atomicWritePaths.length = 0
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\ntui = { animations = false }\n')
|
||||
expect(promotionTestState.atomicWritePaths).not.toContain(systemConfigPath())
|
||||
})
|
||||
|
||||
it('ignores an allowlisted key nested under a [tui.*] subtable', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n[tui.notifications]\ntheme = "should-not-promote"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\n\n[tui.notifications]\ntheme = "changed-in-subtable"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(
|
||||
'model = "gpt-5"\n\n[tui.notifications]\ntheme = "should-not-promote"\n'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('upsertPromotedSettingsInContent', () => {
|
||||
it('replaces a bare key in place inside the [tui] table', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'[tui]\ntheme = "dark"\n',
|
||||
new Map([['tui.theme', '"light"']])
|
||||
)
|
||||
).toBe('[tui]\ntheme = "light"\n')
|
||||
})
|
||||
|
||||
it('inserts a bare key at the end of the [tui] body, before a subtable', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'[tui]\ntheme = "dark"\n\n[tui.notifications]\nenabled = true\n',
|
||||
new Map([['tui.status_line', '["model"]']])
|
||||
)
|
||||
).toBe(
|
||||
'[tui]\ntheme = "dark"\nstatus_line = ["model"]\n\n[tui.notifications]\nenabled = true\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('replaces a dotted preamble tui key in place, keeping the dotted form', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent('tui.theme = "dark"\n', new Map([['tui.theme', '"light"']]))
|
||||
).toBe('tui.theme = "light"\n')
|
||||
})
|
||||
|
||||
it('inserts a dotted tui key beside an existing dotted tui key', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'tui.theme = "dark"\n\n[features]\nx = 1\n',
|
||||
new Map([['tui.status_line', '["model"]']])
|
||||
)
|
||||
).toBe('tui.theme = "dark"\ntui.status_line = ["model"]\n\n[features]\nx = 1\n')
|
||||
})
|
||||
|
||||
it('creates a [tui] table from empty content', () => {
|
||||
expect(upsertPromotedSettingsInContent('', new Map([['tui.theme', '"dark"']]))).toBe(
|
||||
'[tui]\ntheme = "dark"\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('drops an absent key instead of appending [tui] beside an inline tui table', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'tui = { animations = false }\n',
|
||||
new Map([['tui.theme', '"dark"']])
|
||||
)
|
||||
).toBe('tui = { animations = false }\n')
|
||||
})
|
||||
|
||||
it('drops an absent key beside a quoted inline tui table', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'"tui" = { animations = false }\n',
|
||||
new Map([['tui.theme', '"dark"']])
|
||||
)
|
||||
).toBe('"tui" = { animations = false }\n')
|
||||
})
|
||||
|
||||
it('inserts beside a quoted dotted tui key instead of appending a table', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent('"tui" . "pet" = "cat"\n', new Map([['tui.theme', '"dark"']]))
|
||||
).toBe('"tui" . "pet" = "cat"\ntui.theme = "dark"\n')
|
||||
})
|
||||
|
||||
it('creates a [tui] super-table at EOF after a [tui.*] subtable', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'[tui.notifications]\nenabled = true\n',
|
||||
new Map([['tui.theme', '"dark"']])
|
||||
)
|
||||
).toBe('[tui.notifications]\nenabled = true\n\n[tui]\ntheme = "dark"\n')
|
||||
})
|
||||
|
||||
it('drops an absent scalar that would redefine an existing tui key table', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'[tui."theme"]\nvariant = "dark"\n',
|
||||
new Map([['tui.theme', '"light"']])
|
||||
)
|
||||
).toBe('[tui."theme"]\nvariant = "dark"\n')
|
||||
})
|
||||
|
||||
it('drops an absent scalar that would redefine a dotted tui key table', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'tui.theme.variant = "dark"\n',
|
||||
new Map([['tui.theme', '"light"']])
|
||||
)
|
||||
).toBe('tui.theme.variant = "dark"\n')
|
||||
})
|
||||
|
||||
it('does not mistake a dotted tui key inside an array table for a root key', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'[[profiles]]\ntui.theme = "profile-theme"\n',
|
||||
new Map([['tui.theme', '"root-theme"']])
|
||||
)
|
||||
).toBe('[[profiles]]\ntui.theme = "profile-theme"\n\n[tui]\ntheme = "root-theme"\n')
|
||||
})
|
||||
|
||||
it('does not append a table beside a root tui array-of-tables', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'[[tui]]\ntheme = "array-theme"\n',
|
||||
new Map([['tui.theme', '"root-theme"']])
|
||||
)
|
||||
).toBe('[[tui]]\ntheme = "array-theme"\n')
|
||||
})
|
||||
|
||||
it('does not append a table beside a quoted root tui array-of-tables', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'[["tui"]]\ntheme = "array-theme"\n',
|
||||
new Map([['tui.theme', '"root-theme"']])
|
||||
)
|
||||
).toBe('[["tui"]]\ntheme = "array-theme"\n')
|
||||
})
|
||||
|
||||
it('creates one [tui] table for multiple keys reaching the new-table branch', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'',
|
||||
new Map([
|
||||
['tui.status_line', '["model"]'],
|
||||
['tui.theme', '"dark"']
|
||||
])
|
||||
)
|
||||
).toBe('[tui]\nstatus_line = ["model"]\ntheme = "dark"\n')
|
||||
})
|
||||
|
||||
it('routes a mixed top-level + tui batch to its two regions in one rewrite', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "dark"\n',
|
||||
new Map([
|
||||
['model', '"o4"'],
|
||||
['tui.theme', '"light"']
|
||||
])
|
||||
)
|
||||
).toBe('model = "o4"\n\n[tui]\ntheme = "light"\n')
|
||||
})
|
||||
|
||||
it('inserts into a CRLF [tui] table with CRLF endings', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'[tui]\r\ntheme = "dark"\r\n',
|
||||
new Map([['tui.status_line', '["model"]']])
|
||||
)
|
||||
).toBe('[tui]\r\ntheme = "dark"\r\nstatus_line = ["model"]\r\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('upsertTopLevelSettingsInContent', () => {
|
||||
it('writes into empty content', () => {
|
||||
expect(upsertTopLevelSettingsInContent('', new Map([['model', '"x"']]))).toBe('model = "x"\n')
|
||||
|
|
@ -428,6 +802,12 @@ describe('upsertTopLevelSettingsInContent', () => {
|
|||
).toBe('# keep\nmodel = "new"\n\n[t]\nk = 1\n')
|
||||
})
|
||||
|
||||
it('replaces a quoted top-level key instead of adding its bare equivalent', () => {
|
||||
expect(
|
||||
upsertTopLevelSettingsInContent('"model" = "old"\n', new Map([['model', '"new"']]))
|
||||
).toBe('model = "new"\n')
|
||||
})
|
||||
|
||||
it('inserts with CRLF endings into CRLF content', () => {
|
||||
expect(
|
||||
upsertTopLevelSettingsInContent('[features]\r\nhooks = true\r\n', new Map([['model', '"x"']]))
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { readAgentStateFileSync, readAgentStateJsonFileSync } from '../agent-state-file-reader'
|
||||
import { readAgentStateFileSync } from '../agent-state-file-reader'
|
||||
import { writeFileAtomically } from '../codex-accounts/fs-utils'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
|
||||
|
|
@ -18,6 +18,15 @@ import {
|
|||
isTomlStructuralLine,
|
||||
updateTomlLineScanState
|
||||
} from './config-toml-line-scan'
|
||||
import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path'
|
||||
import { tuiStructuredKey, upsertPromotedSettingsInContent } from './codex-config-settings-upsert'
|
||||
import {
|
||||
readCodexSettingsBaseline,
|
||||
writeCodexSettingsBaseline,
|
||||
type CodexSettingsBaseline,
|
||||
type CodexSettingsConflict
|
||||
} from './config-settings-baseline'
|
||||
import { resolveUntrackedCodexSetting } from './config-settings-conflict-resolution'
|
||||
|
||||
// Why: the mirror reverts in-Codex config changes each launch; promotion salvages them by diffing the last baseline.
|
||||
|
||||
|
|
@ -29,65 +38,112 @@ export const PROMOTED_CODEX_SETTING_KEYS = [
|
|||
'sandbox_mode'
|
||||
] as const
|
||||
|
||||
// Why: the [tui] keys the Codex TUI's user-facing pickers persist (status line,
|
||||
// terminal title, theme). Like the top-level list, every key here gets written
|
||||
// into the user's real ~/.codex/config.toml on promotion — grow it deliberately.
|
||||
export const PROMOTED_CODEX_TUI_SETTING_KEYS = [
|
||||
'status_line',
|
||||
'status_line_use_colors',
|
||||
'terminal_title',
|
||||
'theme'
|
||||
] as const
|
||||
|
||||
// Why: promotion diffs and upserts operate on structured keys — top-level keys
|
||||
// keep their bare name, [tui] keys are namespaced tui.<key> so their baseline
|
||||
// entries cannot collide with a top-level key of the same name.
|
||||
const PROMOTED_STRUCTURED_KEYS: readonly string[] = [
|
||||
...PROMOTED_CODEX_SETTING_KEYS,
|
||||
...PROMOTED_CODEX_TUI_SETTING_KEYS.map(tuiStructuredKey)
|
||||
]
|
||||
|
||||
function isPromotedTuiKey(key: string): boolean {
|
||||
return (PROMOTED_CODEX_TUI_SETTING_KEYS as readonly string[]).includes(key)
|
||||
}
|
||||
|
||||
// Returns the structured tui key a scanned line's key represents, or null. In
|
||||
// the preamble it recognizes the dotted `tui.<key>` form a user may hand-author;
|
||||
// inside the first `[tui]` table body it recognizes the bare `<key>` form Codex
|
||||
// writes. Both map to the same structured key so either config shape promotes.
|
||||
function matchTuiStructuredKey(
|
||||
keyPath: string[],
|
||||
inPreamble: boolean,
|
||||
tuiBodyActive: boolean
|
||||
): string | null {
|
||||
if (inPreamble) {
|
||||
const tuiKey = keyPath.length === 2 && keyPath[0] === 'tui' ? keyPath[1] : null
|
||||
return tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null
|
||||
}
|
||||
const tuiKey = keyPath.length === 1 ? keyPath[0] : null
|
||||
return tuiBodyActive && tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null
|
||||
}
|
||||
|
||||
type TopLevelSettingValue = {
|
||||
raw: string
|
||||
// Why: a multiline string/array value can't be replaced line-by-line, so it's excluded from promotion.
|
||||
multiline: boolean
|
||||
}
|
||||
|
||||
type SettingsBaselineFile = {
|
||||
version: 1
|
||||
settings: Record<string, string>
|
||||
}
|
||||
|
||||
function getSettingsBaselinePath(runtimeHomePath: string): string {
|
||||
return join(runtimeHomePath, '.orca-config-settings-baseline.json')
|
||||
}
|
||||
|
||||
function readSettingsBaseline(runtimeHomePath: string): Map<string, string> | null {
|
||||
const baselinePath = getSettingsBaselinePath(runtimeHomePath)
|
||||
if (!existsSync(baselinePath)) {
|
||||
function matchPromotedStructuredKey(
|
||||
line: string,
|
||||
inPreamble: boolean,
|
||||
tuiBodyActive: boolean
|
||||
): { structuredKey: string; raw: string } | null {
|
||||
const parsed = parseTomlKeyPath(line)
|
||||
if (!parsed || line[parsed.end] !== '=') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = readAgentStateJsonFileSync(baselinePath)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
const settings = (parsed as SettingsBaselineFile).settings
|
||||
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
||||
return null
|
||||
}
|
||||
const result = new Map<string, string>()
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (typeof value === 'string') {
|
||||
result.set(key, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return null
|
||||
const raw = line.slice(parsed.end + 1).trim()
|
||||
const topLevelKey = parsed.segments.length === 1 ? parsed.segments[0] : null
|
||||
if (
|
||||
inPreamble &&
|
||||
topLevelKey &&
|
||||
(PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(topLevelKey)
|
||||
) {
|
||||
return { structuredKey: topLevelKey, raw }
|
||||
}
|
||||
const tuiKey = matchTuiStructuredKey(parsed.segments, inPreamble, tuiBodyActive)
|
||||
return tuiKey ? { structuredKey: tuiKey, raw } : null
|
||||
}
|
||||
|
||||
// Why: only top-level preamble keys are scanned; rewriting nested [profiles.*] tables isn't worth the risk here.
|
||||
function readTopLevelSettingValues(configPath: string): Map<string, TopLevelSettingValue> {
|
||||
// Why: top-level preamble scalars keep the historical behavior; [tui] keys are
|
||||
// collected from the first bare [tui] table body or the dotted preamble form,
|
||||
// keyed by structured path. Any table header (including [tui.*] subtables) ends
|
||||
// the [tui] body, and [profiles.*]/other tables are still ignored.
|
||||
function readPromotedSettingValues(configPath: string): Map<string, TopLevelSettingValue> {
|
||||
const result = new Map<string, TopLevelSettingValue>()
|
||||
if (!existsSync(configPath)) {
|
||||
return result
|
||||
}
|
||||
const lines = readAgentStateFileSync(configPath).split('\n')
|
||||
let state = createTomlLineScanState()
|
||||
let inPreamble = true
|
||||
let tuiTableSeen = false
|
||||
let tuiBodyActive = false
|
||||
for (const line of lines) {
|
||||
if (isTomlStructuralLine(state)) {
|
||||
if (getTomlTableHeader(line)) {
|
||||
break
|
||||
const header = getTomlTableHeader(line)
|
||||
if (header) {
|
||||
const table = parseTomlTableHeaderPath(header)
|
||||
tuiBodyActive =
|
||||
table !== null &&
|
||||
!table.isArray &&
|
||||
table.segments.length === 1 &&
|
||||
table.segments[0] === 'tui' &&
|
||||
!tuiTableSeen
|
||||
if (tuiBodyActive) {
|
||||
tuiTableSeen = true
|
||||
}
|
||||
inPreamble = false
|
||||
state = updateTomlLineScanState(state, line)
|
||||
continue
|
||||
}
|
||||
const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=[ \t]*(.*?)[ \t\r]*$/.exec(line)
|
||||
const key = match?.[1]
|
||||
if (key && (PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(key)) {
|
||||
const matched = matchPromotedStructuredKey(line, inPreamble, tuiBodyActive)
|
||||
if (matched) {
|
||||
const nextState = updateTomlLineScanState(state, line)
|
||||
result.set(key, { raw: match?.[2] ?? '', multiline: !isTomlStructuralLine(nextState) })
|
||||
result.set(matched.structuredKey, {
|
||||
raw: matched.raw,
|
||||
multiline: !isTomlStructuralLine(nextState)
|
||||
})
|
||||
state = nextState
|
||||
continue
|
||||
}
|
||||
|
|
@ -103,28 +159,22 @@ function readTopLevelSettingValues(configPath: string): Map<string, TopLevelSett
|
|||
* Call after a successful mirror only — advancing past an unpromoted change strands it forever.
|
||||
*/
|
||||
export function snapshotCodexRuntimeSettingsBaseline(
|
||||
runtimeHomePath = getOrcaManagedCodexHomePath()
|
||||
runtimeHomePath = getOrcaManagedCodexHomePath(),
|
||||
conflicts: ReadonlyMap<string, CodexSettingsConflict> = new Map()
|
||||
): 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 settings: Record<string, string> = {}
|
||||
for (const [key, value] of readTopLevelSettingValues(runtimeTomlPath)) {
|
||||
if (!value.multiline) {
|
||||
settings[key] = value.raw
|
||||
const runtimeValues = readPromotedSettingValues(runtimeTomlPath)
|
||||
const settings = new Map<string, string | null>()
|
||||
for (const key of PROMOTED_STRUCTURED_KEYS) {
|
||||
const value = runtimeValues.get(key)
|
||||
if (!conflicts.has(key) && !value?.multiline) {
|
||||
// Why: explicit nulls distinguish a schema-aware absence from a key added by a later schema.
|
||||
settings.set(key, value?.raw ?? null)
|
||||
}
|
||||
}
|
||||
const file: SettingsBaselineFile = { version: 1, settings }
|
||||
const baselinePath = getSettingsBaselinePath(runtimeHomePath)
|
||||
const serialized = `${JSON.stringify(file, null, 2)}\n`
|
||||
// Why: launch prep runs repeatedly; skip byte-identical rewrites to avoid needless disk writes.
|
||||
if (existsSync(baselinePath) && readAgentStateFileSync(baselinePath) === serialized) {
|
||||
return
|
||||
}
|
||||
writeFileSync(baselinePath, serialized, {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600
|
||||
})
|
||||
writeCodexSettingsBaseline(runtimeHomePath, { settings, conflicts })
|
||||
} catch (error) {
|
||||
console.warn('[codex-settings-promotion] failed to snapshot settings baseline', error)
|
||||
}
|
||||
|
|
@ -135,6 +185,11 @@ export type CodexSettingsPromotionHomes = {
|
|||
systemHomePath: string
|
||||
}
|
||||
|
||||
export type CodexSettingsPromotionPlan = {
|
||||
conflicts: ReadonlyMap<string, CodexSettingsConflict>
|
||||
runtimeValuesToPreserve: ReadonlyMap<string, string | null>
|
||||
}
|
||||
|
||||
function getHostPromotionHomes(): CodexSettingsPromotionHomes {
|
||||
return {
|
||||
runtimeHomePath: getOrcaManagedCodexHomePath(),
|
||||
|
|
@ -147,56 +202,50 @@ function getHostPromotionHomes(): CodexSettingsPromotionHomes {
|
|||
* Runs before the config mirror so promoted values survive it instead of reverting.
|
||||
* WSL callers pass explicit per-distro homes; default is the host runtime home and ~/.codex.
|
||||
*/
|
||||
export function promoteCodexRuntimeSettingsToSystem(homes?: CodexSettingsPromotionHomes): boolean {
|
||||
export function promoteCodexRuntimeSettingsToSystem(
|
||||
homes?: CodexSettingsPromotionHomes
|
||||
): CodexSettingsPromotionPlan | null {
|
||||
try {
|
||||
promoteCodexRuntimeSettingsToSystemUnsafe(homes ?? getHostPromotionHomes())
|
||||
return true
|
||||
return promoteCodexRuntimeSettingsToSystemUnsafe(homes ?? getHostPromotionHomes())
|
||||
} catch (error) {
|
||||
// Why: promotion is best-effort launch prep; a malformed file must not block Codex launch.
|
||||
console.warn('[codex-settings-promotion] failed to promote runtime settings', error)
|
||||
return false
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotionHomes): void {
|
||||
function promoteCodexRuntimeSettingsToSystemUnsafe(
|
||||
homes: CodexSettingsPromotionHomes
|
||||
): CodexSettingsPromotionPlan {
|
||||
const { runtimeHomePath, systemHomePath } = homes
|
||||
const runtimeTomlPath = join(runtimeHomePath, 'config.toml')
|
||||
const systemTomlPath = join(systemHomePath, 'config.toml')
|
||||
if (resolve(runtimeTomlPath) === resolve(systemTomlPath)) {
|
||||
return
|
||||
return emptyPromotionPlan()
|
||||
}
|
||||
if (!existsSync(runtimeTomlPath)) {
|
||||
return
|
||||
return emptyPromotionPlan()
|
||||
}
|
||||
// Why: without a baseline, a stale runtime value looks like a fresh in-Codex change; skip until the mirror writes one.
|
||||
const baseline = readSettingsBaseline(runtimeHomePath)
|
||||
const baseline = readCodexSettingsBaseline(runtimeHomePath)
|
||||
if (!baseline) {
|
||||
return
|
||||
return emptyPromotionPlan()
|
||||
}
|
||||
const runtimeValues = readTopLevelSettingValues(runtimeTomlPath)
|
||||
const systemValues = readTopLevelSettingValues(systemTomlPath)
|
||||
const runtimeValues = readPromotedSettingValues(runtimeTomlPath)
|
||||
const systemValues = readPromotedSettingValues(systemTomlPath)
|
||||
const updates = new Map<string, string>()
|
||||
for (const key of PROMOTED_CODEX_SETTING_KEYS) {
|
||||
const runtime = runtimeValues.get(key)
|
||||
if (!runtime || runtime.multiline) {
|
||||
continue
|
||||
}
|
||||
if (runtime.raw === baseline.get(key)) {
|
||||
// Orca mirrored this value and nothing touched it since — not a change.
|
||||
continue
|
||||
}
|
||||
const system = systemValues.get(key)
|
||||
if (system?.multiline) {
|
||||
continue
|
||||
}
|
||||
// Why: ~/.codex is source of truth — an outside edit since the baseline wins over the in-Codex change.
|
||||
if (system?.raw !== baseline.get(key)) {
|
||||
continue
|
||||
}
|
||||
updates.set(key, runtime.raw)
|
||||
}
|
||||
const conflicts = new Map<string, CodexSettingsConflict>()
|
||||
const runtimeValuesToPreserve = new Map<string, string | null>()
|
||||
collectPromotionChanges({
|
||||
baseline,
|
||||
runtimeValues,
|
||||
systemValues,
|
||||
updates,
|
||||
conflicts,
|
||||
runtimeValuesToPreserve
|
||||
})
|
||||
if (updates.size === 0) {
|
||||
return
|
||||
return { conflicts, runtimeValuesToPreserve }
|
||||
}
|
||||
// Why: a fresh host has no ~/.codex; create it owner-only (holds auth.json) or the atomic write ENOENTs and the mirror wipes it.
|
||||
mkdirSync(systemHomePath, { recursive: true, mode: 0o700 })
|
||||
|
|
@ -205,15 +254,71 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion
|
|||
mkdirSync(dirname(writeTarget.path), { recursive: true, mode: 0o700 })
|
||||
const targetExists = existsSync(writeTarget.path)
|
||||
const systemContent = targetExists ? readAgentStateFileSync(writeTarget.path) : ''
|
||||
const nextContent = upsertTopLevelSettingsInContent(systemContent, updates)
|
||||
const nextContent = upsertPromotedSettingsInContent(systemContent, updates)
|
||||
if (nextContent === systemContent) {
|
||||
return { conflicts, runtimeValuesToPreserve }
|
||||
}
|
||||
if (targetExists && parseWslUncPath(writeTarget.path)) {
|
||||
// Why: \\wsl$ 9P symlink metadata is unreliable; write through the existing file to preserve the WSL-side inode.
|
||||
writeFileSync(writeTarget.path, nextContent, 'utf-8')
|
||||
return
|
||||
return { conflicts, runtimeValuesToPreserve }
|
||||
}
|
||||
writeFileAtomically(writeTarget.path, nextContent, {
|
||||
mode: writeTarget.mode
|
||||
})
|
||||
return { conflicts, runtimeValuesToPreserve }
|
||||
}
|
||||
|
||||
type PromotionCollectionContext = {
|
||||
baseline: CodexSettingsBaseline
|
||||
runtimeValues: ReadonlyMap<string, TopLevelSettingValue>
|
||||
systemValues: ReadonlyMap<string, TopLevelSettingValue>
|
||||
updates: Map<string, string>
|
||||
conflicts: Map<string, CodexSettingsConflict>
|
||||
runtimeValuesToPreserve: Map<string, string | null>
|
||||
}
|
||||
|
||||
function collectPromotionChanges(context: PromotionCollectionContext): void {
|
||||
for (const key of PROMOTED_STRUCTURED_KEYS) {
|
||||
const runtimeRaw = getComparableRaw(context.runtimeValues.get(key))
|
||||
const systemRaw = getComparableRaw(context.systemValues.get(key))
|
||||
if (runtimeRaw === undefined || systemRaw === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
const existingConflict = context.baseline.conflicts.get(key)
|
||||
if (existingConflict || !context.baseline.settings.has(key)) {
|
||||
const resolution = resolveUntrackedCodexSetting(runtimeRaw, systemRaw, existingConflict)
|
||||
if (resolution.action === 'promote-runtime') {
|
||||
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)
|
||||
context.runtimeValuesToPreserve.set(key, runtimeRaw)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (runtimeRaw === null || runtimeRaw === context.baseline.settings.get(key)) {
|
||||
continue
|
||||
}
|
||||
// Why: ~/.codex remains source of truth when both sides changed from a known baseline.
|
||||
if (systemRaw !== context.baseline.settings.get(key)) {
|
||||
continue
|
||||
}
|
||||
context.updates.set(key, runtimeRaw)
|
||||
}
|
||||
}
|
||||
|
||||
function getComparableRaw(value: TopLevelSettingValue | undefined): string | null | undefined {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
return value.multiline ? undefined : value.raw
|
||||
}
|
||||
|
||||
function emptyPromotionPlan(): CodexSettingsPromotionPlan {
|
||||
return { conflicts: new Map(), runtimeValuesToPreserve: new Map() }
|
||||
}
|
||||
|
||||
// Why: follow an existing dotfile-manager symlink and carry its mode forward so an atomic write can't widen a 0600 config.
|
||||
|
|
@ -252,55 +357,3 @@ function resolveDanglingSymlinkTarget(linkPath: string): string {
|
|||
// Why: replacing any link in a cycle would destroy dotfile-manager state; abort instead.
|
||||
throw new Error(`Codex config symlink cycle at ${linkPath}`)
|
||||
}
|
||||
|
||||
export function upsertTopLevelSettingsInContent(
|
||||
content: string,
|
||||
updates: Map<string, string>
|
||||
): string {
|
||||
const lines = content.split('\n')
|
||||
let state = createTomlLineScanState()
|
||||
let preambleEnd = lines.length
|
||||
const keyLineIndexes = new Map<string, number>()
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? ''
|
||||
if (isTomlStructuralLine(state)) {
|
||||
if (getTomlTableHeader(line)) {
|
||||
preambleEnd = index
|
||||
break
|
||||
}
|
||||
const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=/.exec(line)
|
||||
if (match?.[1] && updates.has(match[1])) {
|
||||
keyLineIndexes.set(match[1], index)
|
||||
}
|
||||
}
|
||||
state = updateTomlLineScanState(state, line)
|
||||
}
|
||||
|
||||
// Why: match the file's existing EOL (CRLF split leaves a trailing \r) so a Windows config doesn't go mixed-EOL.
|
||||
const usesCrlf = content.includes('\r\n')
|
||||
const insertions: string[] = []
|
||||
for (const [key, raw] of updates) {
|
||||
const existingIndex = keyLineIndexes.get(key)
|
||||
const rendered = `${key} = ${raw}`
|
||||
if (existingIndex !== undefined) {
|
||||
lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered
|
||||
} else {
|
||||
insertions.push(usesCrlf ? `${rendered}\r` : rendered)
|
||||
}
|
||||
}
|
||||
if (insertions.length > 0) {
|
||||
let insertAt = preambleEnd
|
||||
while (insertAt > 0 && (lines[insertAt - 1] ?? '').trim() === '') {
|
||||
insertAt -= 1
|
||||
}
|
||||
if (insertAt === preambleEnd && preambleEnd < lines.length) {
|
||||
insertions.push(usesCrlf ? '\r' : '')
|
||||
}
|
||||
lines.splice(insertAt, 0, ...insertions)
|
||||
}
|
||||
const result = lines.join('\n')
|
||||
if (result.endsWith('\n') || result.length === 0) {
|
||||
return result
|
||||
}
|
||||
return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import { parseTomlSingleLineStringValue } from './config-toml-line-scan'
|
||||
|
||||
export type ParsedTomlKeyPath = {
|
||||
segments: string[]
|
||||
end: number
|
||||
}
|
||||
|
||||
export type ParsedTomlTableHeaderPath = ParsedTomlKeyPath & {
|
||||
isArray: boolean
|
||||
}
|
||||
|
||||
export function parseTomlTableHeaderPath(header: string): ParsedTomlTableHeaderPath | null {
|
||||
const trimmed = header.trim()
|
||||
let source: string
|
||||
let isArray: boolean
|
||||
if (trimmed.startsWith('[[')) {
|
||||
if (!trimmed.endsWith(']]')) {
|
||||
return null
|
||||
}
|
||||
source = trimmed.slice(2, -2)
|
||||
isArray = true
|
||||
} else {
|
||||
if (!trimmed.startsWith('[') || !trimmed.endsWith(']') || trimmed.endsWith(']]')) {
|
||||
return null
|
||||
}
|
||||
source = trimmed.slice(1, -1)
|
||||
isArray = false
|
||||
}
|
||||
const parsed = parseTomlKeyPath(source)
|
||||
if (!parsed || parsed.end !== source.length) {
|
||||
return null
|
||||
}
|
||||
return { ...parsed, isArray }
|
||||
}
|
||||
|
||||
export function parseTomlKeyPath(source: string, offset = 0): ParsedTomlKeyPath | null {
|
||||
const segments: string[] = []
|
||||
let index = skipTomlKeyWhitespace(source, offset)
|
||||
while (index < source.length) {
|
||||
const quoted = parseTomlSingleLineStringValue(source, index)
|
||||
if (quoted) {
|
||||
segments.push(quoted.value)
|
||||
index = quoted.end
|
||||
} else {
|
||||
const bare = /^[A-Za-z0-9_-]+/.exec(source.slice(index))
|
||||
if (!bare) {
|
||||
return null
|
||||
}
|
||||
segments.push(bare[0])
|
||||
index += bare[0].length
|
||||
}
|
||||
index = skipTomlKeyWhitespace(source, index)
|
||||
if (source[index] !== '.') {
|
||||
return { segments, end: index }
|
||||
}
|
||||
index = skipTomlKeyWhitespace(source, index + 1)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function skipTomlKeyWhitespace(source: string, offset: number): number {
|
||||
let index = offset
|
||||
while (source[index] === ' ' || source[index] === '\t') {
|
||||
index += 1
|
||||
}
|
||||
return index
|
||||
}
|
||||
Loading…
Reference in New Issue