diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index d3c39b0b067..afee3f9dc53 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -495,6 +495,9 @@ describe('CodexRuntimeHomeService', () => { getDefaultWslDistro: () => 'Ubuntu', getWslHome: () => wslHome })) + const wslSystemHomePath = join(wslHome, '.codex') + mkdirSync(wslSystemHomePath, { recursive: true }) + writeFileSync(join(wslSystemHomePath, 'AGENTS.md'), '# WSL instructions\n', 'utf-8') const store = createStore( createSettings({ activeCodexManagedAccountId: null, @@ -520,9 +523,12 @@ describe('CodexRuntimeHomeService', () => { expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ distro: 'Ubuntu', - systemCodexHomePath: join(wslHome, '.codex'), + systemCodexHomePath: wslSystemHomePath, managedCodexHomePath: wslRuntimeHomePath }) + const runtimeAgentsPath = join(wslRuntimeHomePath, 'AGENTS.md') + expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('# WSL instructions\n') + expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') vi.doUnmock('../wsl') diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 2c6259dd98e..29e181bd775 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -38,6 +38,7 @@ import { writeFileAtomically } from './fs-utils' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath, + syncCodexGlobalInstructionsIntoManagedHome, syncSystemCodexResourcesIntoManagedHome } from '../codex/codex-home-paths' import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge' @@ -141,7 +142,7 @@ export class CodexRuntimeHomeService { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget) - this.syncWslConfigSettingsForLaunch(wslTarget, syncedRuntimeHomePath) + this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, syncedRuntimeHomePath) const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) return runtimeHomePath @@ -223,10 +224,7 @@ export class CodexRuntimeHomeService { return home ? this.joinWslPath(home, '.codex') : null } - // Why: WSL needs the same promote-then-mirror transaction as host. It keeps - // in-Orca changes while reconciling a newer external ~/.codex edit before - // advancing the per-distro baseline. Only runs on a materialized runtime. - private syncWslConfigSettingsForLaunch( + private syncWslConfigAndGlobalInstructionsForLaunch( target: CodexAccountSelectionTarget, runtimeHomePath: string | null ): void { @@ -242,6 +240,12 @@ export class CodexRuntimeHomeService { if (!systemHomePath || systemHomePath === runtimeHomePath) { return } + // Why: WSL uses a distro-local CODEX_HOME, so host resource mirroring + // cannot provide the distro user's global instructions. + syncCodexGlobalInstructionsIntoManagedHome({ + systemHomePath, + managedHomePath: runtimeHomePath + }) syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) } diff --git a/src/main/codex/codex-home-paths.test.ts b/src/main/codex/codex-home-paths.test.ts index bd12ae95c79..54860946c22 100644 --- a/src/main/codex/codex-home-paths.test.ts +++ b/src/main/codex/codex-home-paths.test.ts @@ -21,13 +21,28 @@ const { getPathMock, homedirMock } = vi.hoisted(() => ({ })) const { fsMockState } = vi.hoisted(() => ({ - fsMockState: { failSymlink: false } + fsMockState: { + copyCount: 0, + failSymlink: false, + trackedReadCount: 0, + trackedReadPath: null as string | null + } })) vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { ...actual, + cpSync: (...args: Parameters) => { + fsMockState.copyCount += 1 + return actual.cpSync(...args) + }, + readFileSync: (...args: Parameters) => { + if (args[0] === fsMockState.trackedReadPath) { + fsMockState.trackedReadCount += 1 + } + return actual.readFileSync(...args) + }, symlinkSync: (...args: Parameters) => { if (fsMockState.failSymlink) { throw new Error('symlink disabled for test') @@ -51,7 +66,10 @@ vi.mock('node:os', async () => { } }) -import { syncSystemCodexResourcesIntoManagedHome } from './codex-home-paths' +import { + syncCodexGlobalInstructionsIntoManagedHome, + syncSystemCodexResourcesIntoManagedHome +} from './codex-home-paths' let fakeHomeDir: string let userDataDir: string @@ -88,7 +106,10 @@ function mockElectronAppPaths(): void { beforeEach(() => { mockElectronAppPaths() + fsMockState.copyCount = 0 fsMockState.failSymlink = false + fsMockState.trackedReadCount = 0 + fsMockState.trackedReadPath = null fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-resource-home-')) userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-resource-user-data-')) previousUserDataPath = process.env.ORCA_USER_DATA_PATH @@ -229,4 +250,190 @@ describe('syncSystemCodexResourcesIntoManagedHome', () => { expect(lstatSync(runtimeHooksPath).isSymbolicLink()).toBe(true) expectSymbolicLinkTargetIfLinked(runtimeHooksPath, systemHooksPath) }) + + it('mirrors the global AGENTS.md into the managed runtime home so user instructions survive', () => { + const systemAgentsPath = join(getSystemCodexHomePath(), 'AGENTS.md') + const runtimeAgentsPath = join(getRuntimeCodexHomePath(), 'AGENTS.md') + writeFileSync(systemAgentsPath, '# Global instructions\n') + + syncSystemCodexResourcesIntoManagedHome() + + expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('# Global instructions\n') + expectSymbolicLinkTargetIfLinked(runtimeAgentsPath, systemAgentsPath) + }) + + it('skips unchanged global-instruction fallback copies when symlinks fail', () => { + fsMockState.failSymlink = true + const systemAgentsPath = join(getSystemCodexHomePath(), 'AGENTS.md') + writeFileSync(systemAgentsPath, 'first\n') + + syncSystemCodexResourcesIntoManagedHome() + expect(fsMockState.copyCount).toBe(1) + syncSystemCodexResourcesIntoManagedHome() + expect(fsMockState.copyCount).toBe(1) + writeFileSync(systemAgentsPath, 'second\n') + syncSystemCodexResourcesIntoManagedHome() + + expect(fsMockState.copyCount).toBe(2) + expect(readFileSync(join(getRuntimeCodexHomePath(), 'AGENTS.md'), 'utf-8')).toBe('second\n') + }) + + it('mirrors only global instructions when explicit Codex homes are provided', () => { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = join(userDataDir, 'wsl-runtime-home') + mkdirSync(join(systemHomePath, 'skills'), { recursive: true }) + writeFileSync(join(systemHomePath, 'skills', 'system.md'), 'skill\n') + writeFileSync(join(systemHomePath, 'AGENTS.md'), '# WSL instructions\n') + + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + + const runtimeAgentsPath = join(managedHomePath, 'AGENTS.md') + expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('# WSL instructions\n') + // Why: WSL homes are \\wsl.localhost UNC paths, so a host-side symlink would + // store a target the distro cannot resolve; global instructions must be a + // real copy even when symlinks are available. + expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false) + expect(existsSync(join(managedHomePath, 'skills'))).toBe(false) + }) + + // Why: creating file symlinks on Windows requires developer mode; the + // runtime-home integration test still enforces real-copy behavior there. + it.skipIf(process.platform === 'win32')( + 'materializes symlinked global instructions as a real file for WSL', + () => { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = join(userDataDir, 'wsl-runtime-home') + const instructionSourcePath = join(userDataDir, 'global-instructions.md') + writeFileSync(instructionSourcePath, 'linked instructions\n') + symlinkSync(instructionSourcePath, join(systemHomePath, 'AGENTS.md')) + + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + + const runtimeAgentsPath = join(managedHomePath, 'AGENTS.md') + expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false) + expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('linked instructions\n') + } + ) + + it.skipIf(process.platform === 'win32')( + 'replaces an existing system-instruction link despite a malformed marker directory', + () => { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = join(userDataDir, 'wsl-runtime-home') + const systemAgentsPath = join(systemHomePath, 'AGENTS.md') + const runtimeAgentsPath = join(managedHomePath, 'AGENTS.md') + mkdirSync(managedHomePath, { recursive: true }) + writeFileSync(systemAgentsPath, 'system\n') + symlinkSync(systemAgentsPath, runtimeAgentsPath) + mkdirSync(join(managedHomePath, '.orca-resource-copies', 'AGENTS.md.json'), { + recursive: true + }) + + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + + expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false) + expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('system\n') + } + ) + + it('removes an unowned copy when recording copy ownership fails', () => { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = join(userDataDir, 'wsl-runtime-home') + writeFileSync(join(systemHomePath, 'AGENTS.md'), 'system\n') + mkdirSync(managedHomePath, { recursive: true }) + writeFileSync(join(managedHomePath, '.orca-resource-copies'), 'blocks marker directory\n') + + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + + expect(existsSync(join(managedHomePath, 'AGENTS.md'))).toBe(false) + }) + + it('skips unchanged copies, then refreshes and removes owned global instructions', () => { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = join(userDataDir, 'wsl-runtime-home') + const systemAgentsPath = join(systemHomePath, 'AGENTS.md') + const runtimeAgentsPath = join(managedHomePath, 'AGENTS.md') + writeFileSync(systemAgentsPath, 'first\n') + + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + expect(fsMockState.copyCount).toBe(1) + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + expect(fsMockState.copyCount).toBe(1) + writeFileSync(systemAgentsPath, 'second\n') + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + + expect(fsMockState.copyCount).toBe(2) + expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false) + expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('second\n') + rmSync(systemAgentsPath) + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + expect(existsSync(runtimeAgentsPath)).toBe(false) + }) + + it('replaces an owned non-file instruction entry without reading it', () => { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = join(userDataDir, 'wsl-runtime-home') + const runtimeAgentsPath = join(managedHomePath, 'AGENTS.md') + writeFileSync(join(systemHomePath, 'AGENTS.md'), 'system\n') + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + rmSync(runtimeAgentsPath) + mkdirSync(runtimeAgentsPath) + fsMockState.trackedReadPath = runtimeAgentsPath + + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + + expect(fsMockState.trackedReadCount).toBe(0) + expect(lstatSync(runtimeAgentsPath).isFile()).toBe(true) + expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('system\n') + }) + + it('removes owned instructions instead of mirroring a non-file source', () => { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = join(userDataDir, 'wsl-runtime-home') + const systemAgentsPath = join(systemHomePath, 'AGENTS.md') + const runtimeAgentsPath = join(managedHomePath, 'AGENTS.md') + writeFileSync(systemAgentsPath, 'system\n') + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + rmSync(systemAgentsPath) + mkdirSync(systemAgentsPath) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + } finally { + warn.mockRestore() + } + + expect(existsSync(runtimeAgentsPath)).toBe(false) + }) + + it('preserves runtime-owned global instructions in an explicit managed home', () => { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = join(userDataDir, 'wsl-runtime-home') + mkdirSync(managedHomePath, { recursive: true }) + writeFileSync(join(systemHomePath, 'AGENTS.md'), 'system\n') + writeFileSync(join(managedHomePath, 'AGENTS.md'), 'runtime\n') + + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + + expect(readFileSync(join(managedHomePath, 'AGENTS.md'), 'utf-8')).toBe('runtime\n') + }) + + it.skipIf(process.platform === 'win32')( + 'preserves an unowned dangling runtime instruction symlink', + () => { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = join(userDataDir, 'wsl-runtime-home') + const runtimeAgentsPath = join(managedHomePath, 'AGENTS.md') + const missingTargetPath = join(userDataDir, 'missing-runtime-instructions.md') + mkdirSync(managedHomePath, { recursive: true }) + writeFileSync(join(systemHomePath, 'AGENTS.md'), 'system\n') + symlinkSync(missingTargetPath, runtimeAgentsPath) + + syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath }) + + expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(true) + expect(readlinkSync(runtimeAgentsPath)).toBe(missingTargetPath) + } + ) }) diff --git a/src/main/codex/codex-home-paths.ts b/src/main/codex/codex-home-paths.ts index 1f2cf9b294c..1fa525c2e4a 100644 --- a/src/main/codex/codex-home-paths.ts +++ b/src/main/codex/codex-home-paths.ts @@ -7,6 +7,7 @@ import { readlinkSync, rmdirSync, rmSync, + statSync, symlinkSync, unlinkSync, writeFileSync @@ -14,6 +15,8 @@ import { import { homedir } from 'node:os' import { dirname, join } from 'node:path' +const CODEX_GLOBAL_INSTRUCTIONS_ENTRY = 'AGENTS.md' + const CODEX_SYSTEM_RESOURCE_ENTRIES = [ 'skills', 'hooks', @@ -21,7 +24,8 @@ const CODEX_SYSTEM_RESOURCE_ENTRIES = [ 'plugin-state', 'profile-v2', 'themes', - 'prompts' + 'prompts', + CODEX_GLOBAL_INSTRUCTIONS_ENTRY ] as const export function getSystemCodexHomePath(): string { @@ -57,10 +61,28 @@ export function syncSystemCodexResourcesIntoManagedHome(): void { } } +export function syncCodexGlobalInstructionsIntoManagedHome({ + systemHomePath, + managedHomePath +}: { + systemHomePath: string + managedHomePath: string +}): void { + mkdirSync(managedHomePath, { recursive: true }) + // Why: this only runs for WSL runtime homes, whose system + managed homes are + // both \\wsl.localhost UNC paths. A host-side symlink there stores a Windows + // UNC target the distro cannot resolve, so copy the file like the config + // mirror does across the same boundary. + linkSystemCodexResource(systemHomePath, managedHomePath, CODEX_GLOBAL_INSTRUCTIONS_ENTRY, { + preferCopy: true + }) +} + function linkSystemCodexResource( systemHomePath: string, managedHomePath: string, - entryName: string + entryName: string, + { preferCopy = false }: { preferCopy?: boolean } = {} ): void { const sourcePath = join(systemHomePath, entryName) const targetPath = join(managedHomePath, entryName) @@ -68,10 +90,20 @@ function linkSystemCodexResource( removeCopiedResourceIfOwned(targetPath, managedHomePath, entryName, sourcePath) return } + if ( + entryName === CODEX_GLOBAL_INSTRUCTIONS_ENTRY && + !systemResourceIsRegularFile(sourcePath) + ) { + removeCopiedResourceIfOwned(targetPath, managedHomePath, entryName, sourcePath) + console.warn('[codex-home] Ignoring non-file system Codex resource:', entryName) + return + } if (targetAlreadyPointsToSource(targetPath, sourcePath)) { clearCopiedResourceMarker(managedHomePath, entryName) - return + if (!preferCopy || !removeSymlinkEntry(targetPath)) { + return + } } const shouldRefreshFallbackCopy = targetIsOwnedFallbackCopy( targetPath, @@ -79,13 +111,26 @@ function linkSystemCodexResource( entryName, sourcePath ) - if (existsSync(targetPath) && !shouldRefreshFallbackCopy) { + if (pathEntryExists(targetPath) && !shouldRefreshFallbackCopy) { return } if (shouldRefreshFallbackCopy) { + // Why: WSL launch preparation runs before every Codex start. Avoid + // rewriting an unchanged file across the UNC boundary on every launch. + if ( + entryName === CODEX_GLOBAL_INSTRUCTIONS_ENTRY && + copiedFileContentsMatch(sourcePath, targetPath) + ) { + return + } rmSync(targetPath, { recursive: true, force: true }) } + if (preferCopy) { + copySystemCodexResourceAsOwnedFallback(sourcePath, targetPath, managedHomePath, entryName) + return + } + try { const sourceStat = lstatSync(sourcePath) symlinkSync( @@ -95,16 +140,84 @@ function linkSystemCodexResource( ) clearCopiedResourceMarker(managedHomePath, entryName) } catch (error) { + // Why: Windows can reject file symlinks outside developer mode. Copy is + // a fallback for launch-time resources; mark ownership so later syncs can + // refresh the copy without touching user-created runtime resources. + copySystemCodexResourceAsOwnedFallback( + sourcePath, + targetPath, + managedHomePath, + entryName, + error + ) + } +} + +function copySystemCodexResourceAsOwnedFallback( + sourcePath: string, + targetPath: string, + managedHomePath: string, + entryName: string, + symlinkError?: unknown +): void { + try { + rmSync(targetPath, { recursive: true, force: true }) + cpSync(sourcePath, targetPath, { + recursive: true, + force: false, + errorOnExist: true, + // Why: dotfile managers commonly symlink AGENTS.md. WSL needs the file + // contents because a copied host-side link is not usable in the distro. + dereference: entryName === CODEX_GLOBAL_INSTRUCTIONS_ENTRY + }) + markCopiedResource(managedHomePath, entryName, sourcePath) + } catch (copyError) { + // Why: an unmarked copy cannot be refreshed or safely removed later. + // Roll it back instead of stranding stale instructions in the runtime home. try { rmSync(targetPath, { recursive: true, force: true }) - // Why: Windows can reject file symlinks outside developer mode. Copy is - // a fallback for launch-time resources; mark ownership so later syncs can - // refresh the copy without touching user-created runtime resources. - cpSync(sourcePath, targetPath, { recursive: true, force: false, errorOnExist: true }) - markCopiedResource(managedHomePath, entryName, sourcePath) - } catch { - console.warn('[codex-home] Failed to link system Codex resource:', entryName, error) + } catch (cleanupError) { + console.warn( + '[codex-home] Failed to remove incomplete resource copy:', + entryName, + cleanupError + ) } + console.warn( + '[codex-home] Failed to mirror system Codex resource:', + entryName, + symlinkError ?? copyError + ) + } +} + +function systemResourceIsRegularFile(sourcePath: string): boolean { + try { + return statSync(sourcePath).isFile() + } catch { + return false + } +} + +function pathEntryExists(entryPath: string): boolean { + try { + lstatSync(entryPath) + return true + } catch { + return false + } +} + +function copiedFileContentsMatch(sourcePath: string, targetPath: string): boolean { + try { + // Why: reading a FIFO or device synchronously can block Codex launch. + // Follow source symlinks, but only compare two regular files. + if (!statSync(sourcePath).isFile() || !lstatSync(targetPath).isFile()) { + return false + } + return readFileSync(sourcePath).equals(readFileSync(targetPath)) + } catch { + return false } } @@ -159,7 +272,12 @@ function readCopiedResourceSourcePath(managedHomePath: string, entryName: string } function clearCopiedResourceMarker(managedHomePath: string, entryName: string): void { - rmSync(getResourceCopyMarkerPath(managedHomePath, entryName), { force: true }) + // Why: a malformed marker directory must not block Codex launch or prevent + // an owned resource from being repaired. + rmSync(getResourceCopyMarkerPath(managedHomePath, entryName), { + recursive: true, + force: true + }) } function targetIsOwnedFallbackCopy(