diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 7a1facf1420..68094874eab 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -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' }) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 67120f15e53..51696b280d2 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -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() diff --git a/src/main/codex/config-settings-promotion.test.ts b/src/main/codex/config-settings-promotion.test.ts index f81866d8751..a15aa3c77e8 100644 --- a/src/main/codex/config-settings-promotion.test.ts +++ b/src/main/codex/config-settings-promotion.test.ts @@ -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') + }) }) diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts index 934d57ed39e..b18707dcb70 100644 --- a/src/main/codex/config-settings-promotion.ts +++ b/src/main/codex/config-settings-promotion.ts @@ -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 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'}` }