From e5d4d860b32e8f8ff97d2da9a76530c99bfb81a0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Jul 2026 10:21:47 -0400 Subject: [PATCH] fix(codex-auth): share plugin cache across profiles --- src/codex-auth/codex-profile-plugin-cache.ts | 219 ++++++++++ src/codex-auth/codex-profile-resources.ts | 3 + .../codex-profile-resources.test.ts | 398 +++++++++++++++++- 3 files changed, 619 insertions(+), 1 deletion(-) create mode 100644 src/codex-auth/codex-profile-plugin-cache.ts diff --git a/src/codex-auth/codex-profile-plugin-cache.ts b/src/codex-auth/codex-profile-plugin-cache.ts new file mode 100644 index 00000000..432f41fa --- /dev/null +++ b/src/codex-auth/codex-profile-plugin-cache.ts @@ -0,0 +1,219 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { ConfigError } from '../errors/error-types'; +import { symlinkPointsTo } from '../management/shared-manager/fs-helpers'; +import { createLogger } from '../services/logging'; + +const logger = createLogger('codex-auth:resources'); +const FALLBACK_SAFE_SYMLINK_ERRORS = new Set(['EPERM', 'EACCES', 'ENOSYS']); + +export function ensureSharedPluginCache(profileDir: string, sharedCodexHome: string): void { + const targetPath = path.join(sharedCodexHome, 'plugins', 'cache'); + const pluginsPath = path.join(profileDir, 'plugins'); + const linkPath = path.join(pluginsPath, 'cache'); + + ensureProfileLocalPluginsDirectory(pluginsPath); + fs.mkdirSync(targetPath, { recursive: true, mode: 0o700 }); + + const existingStat = lstatIfExists(linkPath); + if (isExpectedCacheLink(linkPath, targetPath, existingStat)) { + return; + } + + let backupPath = existingStat === null ? null : createPluginCacheBackupPath(pluginsPath); + if (backupPath !== null) { + try { + fs.renameSync(linkPath, backupPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + backupPath = null; + } + } + + try { + fs.symlinkSync(targetPath, linkPath, 'dir'); + } catch (err) { + if (isExpectedCacheLink(linkPath, targetPath)) { + removePluginCacheBackup(backupPath); + return; + } + + const pathAfterFailure = lstatIfExists(linkPath); + if (backupPath !== null && pathAfterFailure === null) { + if (restorePluginCacheWithoutOverwrite(linkPath, backupPath)) { + backupPath = null; + } + } + + const code = (err as NodeJS.ErrnoException | null)?.code; + if (!code || !FALLBACK_SAFE_SYMLINK_ERRORS.has(code)) { + throw err; + } + + if (isExpectedCacheLink(linkPath, targetPath)) { + removePluginCacheBackup(backupPath); + return; + } + const fallbackPathStat = lstatIfExists(linkPath); + if (backupPath !== null && fallbackPathStat?.isDirectory()) { + mergeMissingResourceTree(backupPath, linkPath); + removePluginCacheBackup(backupPath); + } + + ensureLocalPluginCacheDirectory(linkPath); + mergeMissingResourceTree(targetPath, linkPath); + process.stderr.write( + `[!] codex-auth: symlink unavailable; using profile-local plugin cache at ${linkPath}. ` + + `Copied missing shared entries; plugin cache updates won't propagate automatically.\n` + ); + logger.warn( + 'codex-auth.plugin-cache-copy-fallback', + 'Copied shared plugin cache after symlink failure', + { + link: linkPath, + target: targetPath, + error: err instanceof Error ? err.message : String(err), + } + ); + return; + } + + removePluginCacheBackup(backupPath); + + logger.stage( + 'dispatch', + 'codex.plugin-cache.symlink.created', + 'Created shared plugin cache symlink', + { + link: linkPath, + target: targetPath, + } + ); +} + +function ensureProfileLocalPluginsDirectory(pluginsPath: string): void { + const pluginsStat = lstatIfExists(pluginsPath); + if (pluginsStat === null) { + fs.mkdirSync(pluginsPath, { recursive: true, mode: 0o700 }); + return; + } + if (!pluginsStat.isDirectory()) { + throw new ConfigError( + 'Refusing plugin cache repair: profile plugins path is not a local directory.' + ); + } +} + +function isExpectedCacheLink( + linkPath: string, + targetPath: string, + stat: fs.Stats | null = lstatIfExists(linkPath) +): boolean { + if (!stat?.isSymbolicLink()) { + return false; + } + return symlinkPointsTo(linkPath, targetPath); +} + +function lstatIfExists(resourcePath: string): fs.Stats | null { + try { + return fs.lstatSync(resourcePath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw err; + } +} + +function createPluginCacheBackupPath(pluginsPath: string): string { + return path.join( + pluginsPath, + `.cache.ccs-backup-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}` + ); +} + +function removePluginCacheBackup(backupPath: string | null): void { + if (backupPath !== null) { + fs.rmSync(backupPath, { recursive: true, force: true }); + } +} + +function ensureLocalPluginCacheDirectory(linkPath: string): void { + let existingStat = lstatIfExists(linkPath); + if (existingStat?.isDirectory()) { + return; + } + if (existingStat !== null) { + throw new ConfigError( + 'Refusing plugin cache fallback: profile cache path is not a local directory.' + ); + } + + try { + fs.mkdirSync(linkPath, { recursive: false, mode: 0o700 }); + return; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { + throw err; + } + } + + existingStat = lstatIfExists(linkPath); + if (!existingStat?.isDirectory()) { + throw new ConfigError( + 'Refusing plugin cache fallback: profile cache path is not a local directory.' + ); + } +} + +function restorePluginCacheWithoutOverwrite(linkPath: string, backupPath: string): boolean { + const backupStat = lstatIfExists(backupPath); + if (!backupStat?.isDirectory()) { + return false; + } + + let linkStat = lstatIfExists(linkPath); + if (linkStat === null) { + try { + fs.mkdirSync(linkPath, { recursive: false, mode: 0o700 }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { + throw err; + } + } + linkStat = lstatIfExists(linkPath); + } + + if (!linkStat?.isDirectory()) { + return false; + } + + mergeMissingResourceTree(backupPath, linkPath); + removePluginCacheBackup(backupPath); + return true; +} + +function mergeMissingResourceTree(sourceDir: string, targetDir: string): void { + for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) { + const sourcePath = path.join(sourceDir, entry.name); + const targetPath = path.join(targetDir, entry.name); + const targetStat = lstatIfExists(targetPath); + + if (targetStat === null) { + fs.cpSync(sourcePath, targetPath, { + recursive: true, + force: false, + errorOnExist: false, + preserveTimestamps: true, + }); + continue; + } + + if (entry.isDirectory() && targetStat.isDirectory()) { + mergeMissingResourceTree(sourcePath, targetPath); + } + } +} diff --git a/src/codex-auth/codex-profile-resources.ts b/src/codex-auth/codex-profile-resources.ts index 7e230ae5..c552cac7 100644 --- a/src/codex-auth/codex-profile-resources.ts +++ b/src/codex-auth/codex-profile-resources.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { createLogger } from '../services/logging'; +import { ensureSharedPluginCache } from './codex-profile-plugin-cache'; const logger = createLogger('codex-auth:resources'); @@ -23,6 +24,8 @@ export function ensureCodexProfileResources( for (const resourceName of SHARED_CODEX_RESOURCE_DIRS) { ensureSharedResourceDir(profileDir, sharedCodexHome, resourceName); } + + ensureSharedPluginCache(profileDir, sharedCodexHome); } function ensureSharedResourceDir( diff --git a/tests/unit/codex-auth/codex-profile-resources.test.ts b/tests/unit/codex-auth/codex-profile-resources.test.ts index 506a9207..215bf248 100644 --- a/tests/unit/codex-auth/codex-profile-resources.test.ts +++ b/tests/unit/codex-auth/codex-profile-resources.test.ts @@ -6,6 +6,22 @@ import * as path from 'path'; let tempDir: string; let profileDir: string; let sharedCodexHome: string; +const cachedSkillRelativePath = path.join( + 'openai-bundled', + 'sites', + '0.1.27', + 'skills', + 'site-builder', + 'SKILL.md' +); +const staleCachedSkillRelativePath = path.join( + 'openai-bundled', + 'sites', + '0.1.26', + 'skills', + 'site-builder', + 'SKILL.md' +); beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-resources-test-')); @@ -18,6 +34,17 @@ beforeEach(() => { ); fs.mkdirSync(path.join(sharedCodexHome, 'skills'), { recursive: true, mode: 0o700 }); fs.writeFileSync(path.join(sharedCodexHome, 'skills', 'review.md'), '# Review\n'); + fs.mkdirSync( + path.join(sharedCodexHome, 'plugins', 'cache', path.dirname(cachedSkillRelativePath)), + { + recursive: true, + mode: 0o700, + } + ); + fs.writeFileSync( + path.join(sharedCodexHome, 'plugins', 'cache', cachedSkillRelativePath), + '# Current shared skill\n' + ); }); afterEach(() => { @@ -26,7 +53,7 @@ afterEach(() => { }); describe('ensureCodexProfileResources', () => { - it('links shared agents and skills into a fresh Codex profile', async () => { + it('exposes shared agents, skills, and plugin cache in a fresh Codex profile', async () => { const { ensureCodexProfileResources } = await import( '../../../src/codex-auth/codex-profile-resources' ); @@ -38,6 +65,291 @@ describe('ensureCodexProfileResources', () => { expect(fs.lstatSync(resourcePath).isSymbolicLink()).toBe(true); expect(fs.readlinkSync(resourcePath)).toBe(path.join(sharedCodexHome, resourceName)); } + + const cachedSkillPath = path.join(profileDir, 'plugins', 'cache', cachedSkillRelativePath); + expect(fs.readFileSync(cachedSkillPath, 'utf8')).toBe('# Current shared skill\n'); + }); + + it('replaces a stale profile-local plugin cache while preserving plugin siblings', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + const pluginsDir = path.join(profileDir, 'plugins'); + const cacheDir = path.join(pluginsDir, 'cache'); + const staleSkillPath = path.join(cacheDir, staleCachedSkillRelativePath); + const currentSkillPath = path.join(cacheDir, cachedSkillRelativePath); + const siblingPath = path.join(pluginsDir, 'marketplaces.json'); + fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(staleSkillPath, '# Stale profile skill\n'); + fs.writeFileSync(siblingPath, '{"preserve":true}\n'); + + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + + expect(fs.lstatSync(cacheDir).isSymbolicLink()).toBe(true); + expect(fs.existsSync(staleSkillPath)).toBe(false); + expect(fs.readFileSync(currentSkillPath, 'utf8')).toBe('# Current shared skill\n'); + expect(fs.readFileSync(siblingPath, 'utf8')).toBe('{"preserve":true}\n'); + }); + + it('refuses a symlinked plugins parent without changing its external target', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + const sharedPluginsDir = path.join(sharedCodexHome, 'plugins'); + const sharedSkillPath = path.join(sharedCodexHome, 'plugins', 'cache', cachedSkillRelativePath); + const profilePluginsDir = path.join(profileDir, 'plugins'); + fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 }); + fs.symlinkSync(sharedPluginsDir, profilePluginsDir, 'dir'); + + expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow( + 'profile plugins path is not a local directory' + ); + + expect(fs.lstatSync(profilePluginsDir).isSymbolicLink()).toBe(true); + expect(fs.lstatSync(path.join(sharedPluginsDir, 'cache')).isDirectory()).toBe(true); + expect(fs.readFileSync(sharedSkillPath, 'utf8')).toBe('# Current shared skill\n'); + }); + + it('keeps the plugin cache projection stable across repeated repairs', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + const cacheDir = path.join(profileDir, 'plugins', 'cache'); + const firstTarget = fs.readlinkSync(cacheDir); + const firstInode = fs.lstatSync(cacheDir).ino; + + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + + expect(fs.readlinkSync(cacheDir)).toBe(firstTarget); + expect(fs.lstatSync(cacheDir).ino).toBe(firstInode); + expect(fs.readFileSync(path.join(cacheDir, cachedSkillRelativePath), 'utf8')).toBe( + '# Current shared skill\n' + ); + }); + + it('keeps a concurrently-created correct cache link instead of restoring stale state', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + const cacheDir = path.join(profileDir, 'plugins', 'cache'); + const staleSkillPath = path.join(cacheDir, staleCachedSkillRelativePath); + fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(staleSkillPath, '# Stale profile skill\n'); + + const realSymlinkSync = fs.symlinkSync.bind(fs); + let injectedConcurrentRepair = false; + const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation( + (...args: Parameters) => { + const [, pathToCreate] = args; + if ( + !injectedConcurrentRepair && + path.resolve(String(pathToCreate)) === path.resolve(cacheDir) + ) { + injectedConcurrentRepair = true; + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + } + return realSymlinkSync(...args); + } + ); + + try { + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + } finally { + symlinkSpy.mockRestore(); + } + + expect(fs.lstatSync(cacheDir).isSymbolicLink()).toBe(true); + expect(fs.existsSync(staleSkillPath)).toBe(false); + expect(fs.readFileSync(path.join(cacheDir, cachedSkillRelativePath), 'utf8')).toBe( + '# Current shared skill\n' + ); + expect( + fs.readdirSync(path.dirname(cacheDir)).some((name) => name.startsWith('.cache.ccs-backup-')) + ).toBe(false); + }); + + it('recovers when another repair moves the stale cache before this repair can rename it', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + const cacheDir = path.join(profileDir, 'plugins', 'cache'); + const staleSkillPath = path.join(cacheDir, staleCachedSkillRelativePath); + fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(staleSkillPath, '# Stale profile skill\n'); + + const realRenameSync = fs.renameSync.bind(fs); + let injectedRenameRace = false; + const renameSpy = spyOn(fs, 'renameSync').mockImplementation( + (...args: Parameters) => { + const [oldPath] = args; + if (!injectedRenameRace && path.resolve(String(oldPath)) === path.resolve(cacheDir)) { + injectedRenameRace = true; + fs.rmSync(cacheDir, { recursive: true, force: true }); + throw Object.assign(new Error('simulated concurrent move'), { code: 'ENOENT' }); + } + return realRenameSync(...args); + } + ); + + try { + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + } finally { + renameSpy.mockRestore(); + } + + expect(fs.lstatSync(cacheDir).isSymbolicLink()).toBe(true); + expect(fs.existsSync(staleSkillPath)).toBe(false); + expect(fs.readFileSync(path.join(cacheDir, cachedSkillRelativePath), 'utf8')).toBe( + '# Current shared skill\n' + ); + }); + + it('does not delete a concurrent cache path when symlink creation loses an EEXIST race', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + const pluginsDir = path.join(profileDir, 'plugins'); + const cacheDir = path.join(pluginsDir, 'cache'); + const staleSkillPath = path.join(cacheDir, staleCachedSkillRelativePath); + const concurrentMarkerPath = path.join(cacheDir, 'concurrent-marker.txt'); + fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(staleSkillPath, '# Stale profile skill\n'); + + const realSymlinkSync = fs.symlinkSync.bind(fs); + const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation( + (...args: Parameters) => { + const [, pathToCreate] = args; + if (path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(concurrentMarkerPath, 'preserve concurrent cache\n'); + throw Object.assign(new Error('simulated concurrent EEXIST'), { code: 'EEXIST' }); + } + return realSymlinkSync(...args); + } + ); + + try { + expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow( + 'simulated concurrent EEXIST' + ); + } finally { + symlinkSpy.mockRestore(); + } + + expect(fs.readFileSync(concurrentMarkerPath, 'utf8')).toBe('preserve concurrent cache\n'); + const backupNames = fs + .readdirSync(pluginsDir) + .filter((name) => name.startsWith('.cache.ccs-backup-')); + expect(backupNames).toHaveLength(1); + expect( + fs.readFileSync(path.join(pluginsDir, backupNames[0], staleCachedSkillRelativePath), 'utf8') + ).toBe('# Stale profile skill\n'); + }); + + it('merges rollback data when a concurrent cache appears during restoration', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + const cacheDir = path.join(profileDir, 'plugins', 'cache'); + const staleSkillPath = path.join(cacheDir, staleCachedSkillRelativePath); + const concurrentMarkerPath = path.join(cacheDir, 'concurrent-marker.txt'); + fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(staleSkillPath, '# Stale profile skill\n'); + + const realSymlinkSync = fs.symlinkSync.bind(fs); + const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation( + (...args: Parameters) => { + const [, pathToCreate] = args; + if (path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) { + throw Object.assign(new Error('simulated filesystem corruption'), { code: 'EIO' }); + } + return realSymlinkSync(...args); + } + ); + const realMkdirSync = fs.mkdirSync.bind(fs); + let injectedRestoreRace = false; + const mkdirSpy = spyOn(fs, 'mkdirSync').mockImplementation( + (...args: Parameters) => { + const [pathToCreate] = args; + if (!injectedRestoreRace && path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) { + injectedRestoreRace = true; + realMkdirSync(cacheDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(concurrentMarkerPath, 'preserve concurrent cache\n'); + throw Object.assign(new Error('simulated concurrent create'), { code: 'EEXIST' }); + } + return realMkdirSync(...args); + } + ); + + try { + expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow( + 'simulated filesystem corruption' + ); + } finally { + mkdirSpy.mockRestore(); + symlinkSpy.mockRestore(); + } + + expect(fs.readFileSync(concurrentMarkerPath, 'utf8')).toBe('preserve concurrent cache\n'); + expect(fs.readFileSync(staleSkillPath, 'utf8')).toBe('# Stale profile skill\n'); + expect( + fs.readdirSync(path.dirname(cacheDir)).some((name) => name.startsWith('.cache.ccs-backup-')) + ).toBe(false); + }); + + it('preserves a concurrent non-directory cache path during fallback restoration', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + const pluginsDir = path.join(profileDir, 'plugins'); + const cacheDir = path.join(pluginsDir, 'cache'); + const staleSkillPath = path.join(cacheDir, staleCachedSkillRelativePath); + fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(staleSkillPath, '# Stale profile skill\n'); + + const realSymlinkSync = fs.symlinkSync.bind(fs); + const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation( + (...args: Parameters) => { + const [, pathToCreate] = args; + if (path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) { + throw Object.assign(new Error('simulated Windows symlink denial'), { code: 'EPERM' }); + } + return realSymlinkSync(...args); + } + ); + const realMkdirSync = fs.mkdirSync.bind(fs); + let injectedRestoreRace = false; + const mkdirSpy = spyOn(fs, 'mkdirSync').mockImplementation( + (...args: Parameters) => { + const [pathToCreate] = args; + if (!injectedRestoreRace && path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) { + injectedRestoreRace = true; + fs.writeFileSync(cacheDir, 'preserve concurrent file\n'); + throw Object.assign(new Error('simulated concurrent create'), { code: 'EEXIST' }); + } + return realMkdirSync(...args); + } + ); + + try { + expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow( + 'profile cache path is not a local directory' + ); + } finally { + mkdirSpy.mockRestore(); + symlinkSpy.mockRestore(); + } + + expect(fs.readFileSync(cacheDir, 'utf8')).toBe('preserve concurrent file\n'); + const backupNames = fs + .readdirSync(pluginsDir) + .filter((name) => name.startsWith('.cache.ccs-backup-')); + expect(backupNames).toHaveLength(1); + expect( + fs.readFileSync(path.join(pluginsDir, backupNames[0], staleCachedSkillRelativePath), 'utf8') + ).toBe('# Stale profile skill\n'); }); it('repairs a missing resource link without changing existing shared files', async () => { @@ -107,4 +419,88 @@ describe('ensureCodexProfileResources', () => { expect(fs.lstatSync(agentsPath).isDirectory()).toBe(true); expect(fs.existsSync(path.join(agentsPath, 'brainstormer.toml'))).toBe(true); }); + + it('preserves a local plugin cache and copies missing shared entries on EPERM', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + + const cacheDir = path.join(profileDir, 'plugins', 'cache'); + fs.rmSync(cacheDir, { recursive: true, force: true }); + fs.mkdirSync(path.join(cacheDir, path.dirname(staleCachedSkillRelativePath)), { + recursive: true, + mode: 0o700, + }); + fs.writeFileSync(path.join(cacheDir, 'local-only.txt'), 'keep me\n'); + fs.writeFileSync(path.join(cacheDir, staleCachedSkillRelativePath), '# Existing stale skill\n'); + + const realSymlinkSync = fs.symlinkSync.bind(fs); + const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation( + (...args: Parameters) => { + const [, pathToCreate] = args; + if (path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) { + throw Object.assign(new Error('simulated Windows symlink denial'), { code: 'EPERM' }); + } + return realSymlinkSync(...args); + } + ); + const origWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = () => true; + + try { + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + } finally { + process.stderr.write = origWrite; + symlinkSpy.mockRestore(); + } + + expect(fs.lstatSync(cacheDir).isDirectory()).toBe(true); + expect(fs.readFileSync(path.join(cacheDir, 'local-only.txt'), 'utf8')).toBe('keep me\n'); + expect(fs.readFileSync(path.join(cacheDir, staleCachedSkillRelativePath), 'utf8')).toBe( + '# Existing stale skill\n' + ); + expect(fs.readFileSync(path.join(cacheDir, cachedSkillRelativePath), 'utf8')).toBe( + '# Current shared skill\n' + ); + }); + + it('restores a stale plugin cache and rethrows an unexpected symlink error', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + + const pluginsDir = path.join(profileDir, 'plugins'); + const cacheDir = path.join(pluginsDir, 'cache'); + const siblingPath = path.join(pluginsDir, 'marketplaces.json'); + fs.rmSync(cacheDir, { recursive: true, force: true }); + fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(path.join(cacheDir, 'local-only.txt'), 'restore me\n'); + fs.writeFileSync(siblingPath, '{"preserve":true}\n'); + + const realSymlinkSync = fs.symlinkSync.bind(fs); + const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation( + (...args: Parameters) => { + const [, pathToCreate] = args; + if (path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) { + throw Object.assign(new Error('simulated filesystem corruption'), { code: 'EIO' }); + } + return realSymlinkSync(...args); + } + ); + + try { + expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow( + 'simulated filesystem corruption' + ); + } finally { + symlinkSpy.mockRestore(); + } + + expect(fs.lstatSync(cacheDir).isDirectory()).toBe(true); + expect(fs.readFileSync(path.join(cacheDir, 'local-only.txt'), 'utf8')).toBe('restore me\n'); + expect(fs.existsSync(path.join(cacheDir, cachedSkillRelativePath))).toBe(false); + expect(fs.readFileSync(siblingPath, 'utf8')).toBe('{"preserve":true}\n'); + }); });