feat(codex): write in-Codex setting changes back to ~/.codex config (#7960)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-07-09 12:17:35 -07:00 committed by GitHub
parent ff661a7ab2
commit 94662c8445
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 551 additions and 0 deletions

View File

@ -14,6 +14,7 @@
"../src/main/codex/codex-config-path-reference-rewrite.ts",
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/codex-hook-identity.ts",
"../src/main/codex/config-settings-promotion.ts",
"../src/main/codex/config-toml-line-scan.ts",
"../src/main/codex/config-toml-trust.ts",
"../src/main/codex/hook-service.ts",

View File

@ -3,6 +3,10 @@ import { dirname, join } from 'node:path'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite'
import {
promoteCodexRuntimeSettingsToSystem,
snapshotCodexRuntimeSettingsBaseline
} from './config-settings-promotion'
import {
createTomlLineScanState,
getTomlTableHeader,
@ -19,11 +23,19 @@ function getSystemCodexConfigTomlPath(): string {
}
export function syncSystemConfigIntoManagedCodexHome(): void {
// 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.
promoteCodexRuntimeSettingsToSystem()
try {
syncSystemConfigIntoManagedCodexHomeUnsafe()
} 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()
}
function syncSystemConfigIntoManagedCodexHomeUnsafe(): void {

View File

@ -0,0 +1,296 @@
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'
import { upsertTopLevelSettingsInContent } from './config-settings-promotion'
let tmpHome: string
let userDataDir: string
let previousUserDataPath: string | undefined
beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-settings-home-'))
userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-settings-user-data-'))
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
process.env.ORCA_USER_DATA_PATH = userDataDir
homedirMock.mockReturnValue(tmpHome)
// 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) {
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 runtimeHomeDir(): string {
return join(userDataDir, 'codex-runtime-home', 'home')
}
function runtimeConfigPath(): string {
return join(runtimeHomeDir(), 'config.toml')
}
function baselinePath(): string {
return join(runtimeHomeDir(), '.orca-config-settings-baseline.json')
}
function writeSystemConfig(content: string): void {
mkdirSync(join(tmpHome, '.codex'), { recursive: true })
writeFileSync(systemConfigPath(), content, 'utf-8')
}
function readSystemConfig(): string {
return readFileSync(systemConfigPath(), 'utf-8')
}
function readRuntimeConfig(): string {
return readFileSync(runtimeConfigPath(), 'utf-8')
}
// Mimics how Codex (toml_edit) persists a /model or /approvals change: the
// top-level key line is rewritten in place, or created when absent.
function simulateCodexSettingWrite(key: string, rawValue: string): void {
mkdirSync(runtimeHomeDir(), { recursive: true })
const existing = existsSync(runtimeConfigPath()) ? readFileSync(runtimeConfigPath(), 'utf-8') : ''
const linePattern = new RegExp(`^${key}[ \\t]*=.*$`, 'm')
const rendered = `${key} = ${rawValue}`
const next = linePattern.test(existing)
? existing.replace(linePattern, rendered)
: `${rendered}\n${existing}`
writeFileSync(runtimeConfigPath(), next, 'utf-8')
}
function simulateCodexSettingRemoval(key: string): void {
const existing = readFileSync(runtimeConfigPath(), 'utf-8')
const linePattern = new RegExp(`^${key}[ \\t]*=.*\\n?`, 'm')
writeFileSync(runtimeConfigPath(), existing.replace(linePattern, ''), 'utf-8')
}
describe('codex settings write-back promotion', () => {
it('promotes an in-Codex model change to ~/.codex and reaches a steady state', () => {
writeSystemConfig(
'model = "gpt-5"\napproval_policy = "on-request"\n\n[features]\nhooks = true\n'
)
syncSystemConfigIntoManagedCodexHome()
expect(existsSync(baselinePath())).toBe(true)
simulateCodexSettingWrite('model', '"gpt-5.5-codex"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "gpt-5.5-codex"\napproval_policy = "on-request"\n\n[features]\nhooks = true\n'
)
expect(readRuntimeConfig()).toContain('model = "gpt-5.5-codex"')
const settledSystem = readSystemConfig()
const settledRuntime = readRuntimeConfig()
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(settledSystem)
expect(readRuntimeConfig()).toBe(settledRuntime)
})
it('promotes multiple approvals keys in one pass', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('approval_policy', '"never"')
simulateCodexSettingWrite('sandbox_mode', '"danger-full-access"')
syncSystemConfigIntoManagedCodexHome()
const system = readSystemConfig()
expect(system).toContain('approval_policy = "never"')
expect(system).toContain('sandbox_mode = "danger-full-access"')
expect(system).toContain('model = "gpt-5"')
})
it('does not promote on the first pass without a baseline, then promotes after one', () => {
writeSystemConfig('model = "gpt-5"\n')
mkdirSync(runtimeHomeDir(), { recursive: true })
// Pre-upgrade state: runtime already diverged, but no baseline exists.
writeFileSync(runtimeConfigPath(), 'model = "user-changed-before-upgrade"\n', 'utf-8')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\n')
expect(readRuntimeConfig()).toContain('model = "gpt-5"')
expect(existsSync(baselinePath())).toBe(true)
simulateCodexSettingWrite('model', '"o4"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "o4"\n')
})
it('treats a corrupt baseline as missing and rewrites it', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
writeFileSync(baselinePath(), 'not json', 'utf-8')
simulateCodexSettingWrite('model', '"o4"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\n')
expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 1 })
simulateCodexSettingWrite('model', '"o4"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "o4"\n')
})
it('lets an outside ~/.codex edit win over a conflicting in-Codex change', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('model', '"in-codex-choice"')
writeSystemConfig('model = "outside-edit"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "outside-edit"\n')
expect(readRuntimeConfig()).toContain('model = "outside-edit"')
})
it('inserts a key ~/.codex lacks into the preamble without disturbing the rest', () => {
writeSystemConfig('# my codex config\nmodel = "gpt-5"\n\n[features]\nhooks = true\n')
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('approval_policy', '"on-request"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'# my codex config\nmodel = "gpt-5"\napproval_policy = "on-request"\n\n[features]\nhooks = true\n'
)
})
it('creates ~/.codex/config.toml when a user without one changes a setting', () => {
mkdirSync(join(tmpHome, '.codex'), { recursive: true })
syncSystemConfigIntoManagedCodexHome()
expect(existsSync(baselinePath())).toBe(true)
// Codex itself creates the runtime config.toml on the first /model write.
simulateCodexSettingWrite('model', '"gpt-5.5-codex"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5.5-codex"\n')
expect(readRuntimeConfig()).toContain('model = "gpt-5.5-codex"')
})
it('does not promote a key deletion', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingRemoval('model')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\n')
expect(readRuntimeConfig()).toContain('model = "gpt-5"')
})
it('ignores keys outside the allowlist', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('notify', '["custom-notifier"]')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\n')
})
it('ignores allowlisted keys inside tables such as [profiles.*]', () => {
writeSystemConfig('model = "gpt-5"\n\n[profiles.dev]\nmodel = "profile-model"\n')
syncSystemConfigIntoManagedCodexHome()
const runtime = readRuntimeConfig()
writeFileSync(
runtimeConfigPath(),
runtime.replace('model = "profile-model"', 'model = "profile-changed"'),
'utf-8'
)
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\n\n[profiles.dev]\nmodel = "profile-model"\n')
})
it('never rewrites a multiline system value', () => {
writeSystemConfig('model = """\nodd\nmultiline\n"""\n')
syncSystemConfigIntoManagedCodexHome()
writeFileSync(runtimeConfigPath(), 'model = "single"\n', 'utf-8')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = """\nodd\nmultiline\n"""\n')
})
it('preserves CRLF line endings when replacing a value', () => {
writeSystemConfig('model = "gpt-5"\r\napproval_policy = "on-request"\r\n')
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('model', '"o4"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toContain('model = "o4"\r\n')
expect(readSystemConfig()).toContain('approval_policy = "on-request"\r\n')
})
it('promotes over a value that carried an inline comment', () => {
writeSystemConfig('model = "gpt-5" # my favorite\n')
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('model', '"o4"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "o4"\n')
})
})
describe('upsertTopLevelSettingsInContent', () => {
it('writes into empty content', () => {
expect(upsertTopLevelSettingsInContent('', new Map([['model', '"x"']]))).toBe('model = "x"\n')
})
it('inserts before the first table with a separating blank line', () => {
expect(
upsertTopLevelSettingsInContent('[features]\nhooks = true\n', new Map([['model', '"x"']]))
).toBe('model = "x"\n\n[features]\nhooks = true\n')
})
it('appends to a preamble-only file without a trailing newline', () => {
expect(
upsertTopLevelSettingsInContent('approval_policy = "never"', new Map([['model', '"x"']]))
).toBe('approval_policy = "never"\nmodel = "x"\n')
})
it('replaces the existing line in place', () => {
expect(
upsertTopLevelSettingsInContent(
'# keep\nmodel = "old"\n\n[t]\nk = 1\n',
new Map([['model', '"new"']])
)
).toBe('# keep\nmodel = "new"\n\n[t]\nk = 1\n')
})
})

View File

@ -0,0 +1,242 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
import {
createTomlLineScanState,
getTomlTableHeader,
isTomlStructuralLine,
updateTomlLineScanState
} from './config-toml-line-scan'
// Why: the config mirror rewrites the runtime config.toml from ~/.codex on
// every launch (and on background rate-limit fetches), so settings the user
// changes inside Orca-launched Codex silently revert. Promotion diffs the
// runtime file against a baseline of what Orca last wrote — anything that
// differs is a change Codex persisted for the user and belongs in ~/.codex.
// Why: only the user-preference scalars the Codex TUI itself persists
// (/model writes model + model_reasoning_effort, /approvals writes
// approval_policy + sandbox_mode). Every key added here gets written into the
// user's real ~/.codex/config.toml, so grow this list deliberately.
export const PROMOTED_CODEX_SETTING_KEYS = [
'model',
'model_reasoning_effort',
'approval_policy',
'sandbox_mode'
] as const
type TopLevelSettingValue = {
raw: string
// Why: a value that opens a multiline string/array cannot be replaced or
// copied line-by-line safely, so it is excluded from promotion entirely.
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)) {
return null
}
try {
const parsed: unknown = JSON.parse(readFileSync(baselinePath, 'utf-8'))
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
}
}
// Why: only keys in the top-level preamble are scanned — Codex writes profile
// overrides into [profiles.*] tables, and rewriting nested tables surgically
// is not worth the risk for stage-1 promotion.
function readTopLevelSettingValues(configPath: string): Map<string, TopLevelSettingValue> {
const result = new Map<string, TopLevelSettingValue>()
if (!existsSync(configPath)) {
return result
}
const lines = readFileSync(configPath, 'utf-8').split('\n')
let state = createTomlLineScanState()
for (const line of lines) {
if (isTomlStructuralLine(state)) {
if (getTomlTableHeader(line)) {
break
}
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 nextState = updateTomlLineScanState(state, line)
result.set(key, { raw: match?.[2] ?? '', multiline: !isTomlStructuralLine(nextState) })
state = nextState
continue
}
}
state = updateTomlLineScanState(state, line)
}
return result
}
/**
* Records the promotable top-level settings the runtime config.toml holds
* after a mirror, so the next promotion can tell "value Orca mirrored" apart
* from "value Codex wrote for the user". Call after a successful mirror only
* advancing the baseline past an unpromoted change would strand it forever.
*/
export function snapshotCodexRuntimeSettingsBaseline(): void {
try {
const runtimeHomePath = getOrcaManagedCodexHomePath()
const runtimeTomlPath = join(runtimeHomePath, 'config.toml')
// Why: a missing runtime config still records an empty baseline — when
// Codex later creates the file for a user with no ~/.codex/config.toml,
// that first change must diff against "Orca left nothing" and promote.
const settings: Record<string, string> = {}
for (const [key, value] of readTopLevelSettingValues(runtimeTomlPath)) {
if (!value.multiline) {
settings[key] = value.raw
}
}
const file: SettingsBaselineFile = { version: 1, settings }
writeFileSync(getSettingsBaselinePath(runtimeHomePath), `${JSON.stringify(file, null, 2)}\n`, {
encoding: 'utf-8',
mode: 0o600
})
} catch (error) {
console.warn('[codex-settings-promotion] failed to snapshot settings baseline', error)
}
}
/**
* Promotes setting changes the user made inside Orca-launched Codex (written
* by Codex into the runtime config.toml) into ~/.codex/config.toml. Runs
* before the config mirror so the promoted values survive the same mirror
* pass instead of reverting.
*/
export function promoteCodexRuntimeSettingsToSystem(): void {
try {
promoteCodexRuntimeSettingsToSystemUnsafe()
} catch (error) {
// Why: promotion is best-effort launch prep; a malformed runtime file
// must not block the config mirror or the Codex launch itself.
console.warn('[codex-settings-promotion] failed to promote runtime settings', error)
}
}
function promoteCodexRuntimeSettingsToSystemUnsafe(): void {
const runtimeHomePath = getOrcaManagedCodexHomePath()
const systemHomePath = getSystemCodexHomePath()
const runtimeTomlPath = join(runtimeHomePath, 'config.toml')
const systemTomlPath = join(systemHomePath, 'config.toml')
if (resolve(runtimeTomlPath) === resolve(systemTomlPath)) {
return
}
if (!existsSync(runtimeTomlPath)) {
return
}
// Why: without a baseline of what Orca last mirrored (first launch after
// upgrading to a build with promotion, or a corrupted snapshot), a stale
// runtime value is indistinguishable from a fresh in-Codex change. Skip
// this pass — the mirror writes the first baseline and promotion starts on
// the next one.
const baseline = readSettingsBaseline(runtimeHomePath)
if (!baseline) {
return
}
const runtimeValues = readTopLevelSettingValues(runtimeTomlPath)
const systemValues = readTopLevelSettingValues(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 stays source of truth — if the user also edited it there
// since the baseline, the outside edit wins over the in-Codex change.
if (system?.raw !== baseline.get(key)) {
continue
}
updates.set(key, runtime.raw)
}
if (updates.size === 0) {
return
}
const systemContent = existsSync(systemTomlPath) ? readFileSync(systemTomlPath, 'utf-8') : ''
writeFileAtomically(systemTomlPath, upsertTopLevelSettingsInContent(systemContent, updates))
}
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)
}
const insertions: string[] = []
for (const [key, raw] of updates) {
const existingIndex = keyLineIndexes.get(key)
const rendered = `${key} = ${raw}`
if (existingIndex !== undefined) {
// Why: CRLF configs keep a trailing \r after the split; preserve it so
// the rewritten line matches the file's existing endings.
lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered
} else {
insertions.push(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('')
}
lines.splice(insertAt, 0, ...insertions)
}
const result = lines.join('\n')
return result.endsWith('\n') || result.length === 0 ? result : `${result}\n`
}