Support WSL Codex settings promotion and harden config write-back

- Enable settings promotion for WSL runtimes using per-distro baselines.
- Create parent directories if missing to prevent promotion ENOENTs.
- Keep restrictive permissions (0600) and follow symlinks on promote.
- Respect CRLF line endings when inserting keys into CRLF config files.
- Skip redundant baseline file writes when settings are unchanged.
- Include the release scan report for the 1.4.131-rc2 prep.
This commit is contained in:
Jinjing
2026-07-09 21:12:40 -07:00
parent feac6a5104
commit 7579999eba
4 changed files with 249 additions and 26 deletions
@@ -533,6 +533,63 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('promotes WSL in-Codex setting changes into the distro ~/.codex config', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const wslHome = join(testState.userDataDir, 'wsl-home')
vi.doMock('../wsl', () => ({
getDefaultWslDistro: () => 'Ubuntu',
getWslHome: () => wslHome
}))
const store = createStore(
createSettings({
activeCodexManagedAccountId: null,
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } }
})
)
const wslSystemConfigPath = join(wslHome, '.codex', 'config.toml')
mkdirSync(join(wslHome, '.codex'), { recursive: true })
writeFileSync(wslSystemConfigPath, 'model = "gpt-5"\n', 'utf-8')
try {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
const wslRuntimeHomePath = join(
wslHome,
'.local',
'share',
'orca',
'codex-runtime-home',
'home'
)
// First fetch seeds the runtime config and records the per-distro baseline.
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
wslRuntimeHomePath
)
const baselinePath = join(wslRuntimeHomePath, '.orca-config-settings-baseline.json')
expect(existsSync(baselinePath)).toBe(true)
// Codex persists a /model change into the WSL runtime config.
const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml')
writeFileSync(
runtimeConfigPath,
readFileSync(runtimeConfigPath, 'utf-8').replace('model = "gpt-5"', 'model = "o4"'),
'utf-8'
)
service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "o4"\n')
// Baseline advances so the promoted value is not re-promoted forever.
expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"o4\\""')
} finally {
vi.doUnmock('../wsl')
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
}
})
it('bridges WSL history from a configured per-distro source-home override', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
@@ -49,6 +49,10 @@ import {
prepareSystemConfigForFreshRuntimeMirror,
syncSystemConfigIntoManagedCodexHome
} from '../codex/codex-config-mirror'
import {
promoteCodexRuntimeSettingsToSystem,
snapshotCodexRuntimeSettingsBaseline
} from '../codex/config-settings-promotion'
import { parseWslUncPath } from '../../shared/wsl-paths'
import {
getWslSelectionKey,
@@ -139,9 +143,9 @@ export class CodexRuntimeHomeService {
prepareForCodexLaunch(target?: CodexAccountSelectionTarget): string | null {
if (target?.runtime === 'wsl') {
const wslTarget = this.resolveWslDefaultTarget(target)
const runtimeHomePath =
this.syncWslRuntimeForCurrentSelection(wslTarget) ??
this.getWslSystemCodexHomePath(wslTarget)
const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget)
this.syncWslConfigSettingsPromotion(wslTarget, syncedRuntimeHomePath)
const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath)
return runtimeHomePath
}
@@ -222,13 +226,38 @@ export class CodexRuntimeHomeService {
return home ? this.joinWslPath(home, '.codex') : null
}
// Why: WSL runtime homes are seeded once and never re-mirrored, so /model
// and /approvals changes Codex writes there would otherwise stay split from
// the distro's real ~/.codex forever. Promote them with a per-distro
// baseline stored inside that runtime home. Only runs against a materialized
// runtime home — never against the system home fallback, which would drop
// an Orca baseline file into the user's real ~/.codex.
private syncWslConfigSettingsPromotion(
target: CodexAccountSelectionTarget,
runtimeHomePath: string | null
): void {
if (!runtimeHomePath) {
return
}
const distro =
parseWslUncPath(runtimeHomePath)?.distro || target.wslDistro?.trim() || getDefaultWslDistro()
if (!distro) {
return
}
const systemHomePath = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro })
if (!systemHomePath || systemHomePath === runtimeHomePath) {
return
}
promoteCodexRuntimeSettingsToSystem({ runtimeHomePath, systemHomePath })
snapshotCodexRuntimeSettingsBaseline(runtimeHomePath)
}
prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): string | null {
if (target?.runtime === 'wsl') {
const wslTarget = this.resolveWslDefaultTarget(target)
return (
this.syncWslRuntimeForCurrentSelection(wslTarget) ??
this.getWslSystemCodexHomePath(wslTarget)
)
const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget)
this.syncWslConfigSettingsPromotion(wslTarget, syncedRuntimeHomePath)
return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
}
this.syncForCurrentSelection()
syncSystemCodexResourcesIntoManagedHome()
@@ -1,5 +1,17 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import {
chmodSync,
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
utimesSync,
writeFileSync
} from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import type * as Os from 'node:os'
import { join } from 'node:path'
@@ -188,7 +200,9 @@ describe('codex settings write-back promotion', () => {
})
it('creates ~/.codex/config.toml when a user without one changes a setting', () => {
mkdirSync(join(tmpHome, '.codex'), { recursive: true })
// Why: no mkdir of ~/.codex here — a genuinely fresh host has neither the
// config nor its directory, and promotion must create both.
expect(existsSync(join(tmpHome, '.codex'))).toBe(false)
syncSystemConfigIntoManagedCodexHome()
expect(existsSync(baselinePath())).toBe(true)
@@ -266,6 +280,73 @@ describe('codex settings write-back promotion', () => {
expect(readSystemConfig()).toBe('model = "o4"\n')
})
it.skipIf(process.platform === 'win32')(
'preserves a restrictive mode on the existing ~/.codex/config.toml',
() => {
writeSystemConfig('model = "gpt-5"\n')
chmodSync(systemConfigPath(), 0o600)
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('model', '"o4"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "o4"\n')
expect(statSync(systemConfigPath()).mode & 0o777).toBe(0o600)
}
)
it.skipIf(process.platform === 'win32')(
'creates a new ~/.codex/config.toml with owner-only permissions',
() => {
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('model', '"o4"')
syncSystemConfigIntoManagedCodexHome()
expect(statSync(systemConfigPath()).mode & 0o777).toBe(0o600)
}
)
it.skipIf(process.platform === 'win32')(
'writes through a symlinked config.toml without replacing the link',
() => {
mkdirSync(join(tmpHome, '.codex'), { recursive: true })
const realConfigPath = join(tmpHome, 'dotfiles-config.toml')
writeFileSync(realConfigPath, 'model = "gpt-5"\n', 'utf-8')
symlinkSync(realConfigPath, systemConfigPath())
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('model', '"o4"')
syncSystemConfigIntoManagedCodexHome()
expect(lstatSync(systemConfigPath()).isSymbolicLink()).toBe(true)
expect(readFileSync(realConfigPath, 'utf-8')).toBe('model = "o4"\n')
}
)
it('inserts a missing key into a CRLF config with CRLF endings', () => {
writeSystemConfig('model = "gpt-5"\r\n\r\n[features]\r\nhooks = true\r\n')
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('approval_policy', '"never"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "gpt-5"\r\napproval_policy = "never"\r\n\r\n[features]\r\nhooks = true\r\n'
)
})
it('does not rewrite an unchanged settings baseline', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
const past = new Date(Date.now() - 120_000)
utimesSync(baselinePath(), past, past)
syncSystemConfigIntoManagedCodexHome()
expect(statSync(baselinePath()).mtimeMs).toBeLessThan(Date.now() - 60_000)
})
})
describe('upsertTopLevelSettingsInContent', () => {
@@ -293,4 +374,16 @@ describe('upsertTopLevelSettingsInContent', () => {
)
).toBe('# keep\nmodel = "new"\n\n[t]\nk = 1\n')
})
it('inserts with CRLF endings into CRLF content', () => {
expect(
upsertTopLevelSettingsInContent('[features]\r\nhooks = true\r\n', new Map([['model', '"x"']]))
).toBe('model = "x"\r\n\r\n[features]\r\nhooks = true\r\n')
})
it('appends with CRLF to a CRLF preamble-only file', () => {
expect(
upsertTopLevelSettingsInContent('approval_policy = "never"\r\n', new Map([['model', '"x"']]))
).toBe('approval_policy = "never"\r\nmodel = "x"\r\n')
})
})
+61 -17
View File
@@ -1,4 +1,4 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
@@ -103,9 +103,10 @@ function readTopLevelSettingValues(configPath: string): Map<string, TopLevelSett
* 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 {
export function snapshotCodexRuntimeSettingsBaseline(
runtimeHomePath = getOrcaManagedCodexHomePath()
): 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,
@@ -117,7 +118,14 @@ export function snapshotCodexRuntimeSettingsBaseline(): void {
}
}
const file: SettingsBaselineFile = { version: 1, settings }
writeFileSync(getSettingsBaselinePath(runtimeHomePath), `${JSON.stringify(file, null, 2)}\n`, {
const baselinePath = getSettingsBaselinePath(runtimeHomePath)
const serialized = `${JSON.stringify(file, null, 2)}\n`
// Why: this runs on every background quota poll; skip byte-identical
// rewrites so an unchanged refresh cycle does no disk writes.
if (existsSync(baselinePath) && readFileSync(baselinePath, 'utf-8') === serialized) {
return
}
writeFileSync(baselinePath, serialized, {
encoding: 'utf-8',
mode: 0o600
})
@@ -126,15 +134,28 @@ export function snapshotCodexRuntimeSettingsBaseline(): void {
}
}
export type CodexSettingsPromotionHomes = {
runtimeHomePath: string
systemHomePath: string
}
function getHostPromotionHomes(): CodexSettingsPromotionHomes {
return {
runtimeHomePath: getOrcaManagedCodexHomePath(),
systemHomePath: getSystemCodexHomePath()
}
}
/**
* 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.
* pass instead of reverting. WSL callers pass explicit per-distro homes; the
* default is the host runtime home and host ~/.codex.
*/
export function promoteCodexRuntimeSettingsToSystem(): void {
export function promoteCodexRuntimeSettingsToSystem(homes?: CodexSettingsPromotionHomes): void {
try {
promoteCodexRuntimeSettingsToSystemUnsafe()
promoteCodexRuntimeSettingsToSystemUnsafe(homes ?? getHostPromotionHomes())
} catch (error) {
// Why: promotion is best-effort launch prep; a malformed runtime file
// must not block the config mirror or the Codex launch itself.
@@ -142,9 +163,8 @@ export function promoteCodexRuntimeSettingsToSystem(): void {
}
}
function promoteCodexRuntimeSettingsToSystemUnsafe(): void {
const runtimeHomePath = getOrcaManagedCodexHomePath()
const systemHomePath = getSystemCodexHomePath()
function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotionHomes): void {
const { runtimeHomePath, systemHomePath } = homes
const runtimeTomlPath = join(runtimeHomePath, 'config.toml')
const systemTomlPath = join(systemHomePath, 'config.toml')
if (resolve(runtimeTomlPath) === resolve(systemTomlPath)) {
@@ -188,8 +208,28 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(): void {
if (updates.size === 0) {
return
}
const systemContent = existsSync(systemTomlPath) ? readFileSync(systemTomlPath, 'utf-8') : ''
writeFileAtomically(systemTomlPath, upsertTopLevelSettingsInContent(systemContent, updates))
// Why: a genuinely fresh host has no ~/.codex yet; without the directory
// the atomic write ENOENTs and the following mirror wipes the setting.
mkdirSync(systemHomePath, { recursive: true })
const writeTarget = resolvePromotionWriteTarget(systemTomlPath)
const systemContent = existsSync(writeTarget.path) ? readFileSync(writeTarget.path, 'utf-8') : ''
writeFileAtomically(writeTarget.path, upsertTopLevelSettingsInContent(systemContent, updates), {
mode: writeTarget.mode
})
}
// Why: promotion rewrites the user's real config.toml. Follow an existing
// symlink (dotfile managers) instead of replacing the link with a plain file,
// and carry the real file's mode forward — an atomic write without a mode
// would widen a user-restricted 0600 config to the process umask default.
// A new or unreadable target is created owner-only.
function resolvePromotionWriteTarget(systemTomlPath: string): { path: string; mode: number } {
try {
const realPath = realpathSync(systemTomlPath)
return { path: realPath, mode: statSync(realPath).mode & 0o777 }
} catch {
return { path: systemTomlPath, mode: 0o600 }
}
}
export function upsertTopLevelSettingsInContent(
@@ -215,16 +255,17 @@ export function upsertTopLevelSettingsInContent(
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) {
// 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)
insertions.push(usesCrlf ? `${rendered}\r` : rendered)
}
}
if (insertions.length > 0) {
@@ -233,10 +274,13 @@ export function upsertTopLevelSettingsInContent(
insertAt -= 1
}
if (insertAt === preambleEnd && preambleEnd < lines.length) {
insertions.push('')
insertions.push(usesCrlf ? '\r' : '')
}
lines.splice(insertAt, 0, ...insertions)
}
const result = lines.join('\n')
return result.endsWith('\n') || result.length === 0 ? result : `${result}\n`
if (result.endsWith('\n') || result.length === 0) {
return result
}
return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}`
}