mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 08:02:44 +00:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user