From e5d4d860b32e8f8ff97d2da9a76530c99bfb81a0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Jul 2026 10:21:47 -0400 Subject: [PATCH 1/4] 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'); + }); }); From 960d6e23a0c85aef6fceef2afbe789e285c5d940 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Jul 2026 10:22:09 -0400 Subject: [PATCH 2/4] test(codex-auth): cover profile cache repair entry points --- tests/unit/bin/codex-runtime-router.test.ts | 36 +++++++++++++++++++ .../commands/create-command.test.ts | 33 +++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/tests/unit/bin/codex-runtime-router.test.ts b/tests/unit/bin/codex-runtime-router.test.ts index e9380831..7a639429 100644 --- a/tests/unit/bin/codex-runtime-router.test.ts +++ b/tests/unit/bin/codex-runtime-router.test.ts @@ -176,9 +176,45 @@ describe('codex-runtime router — non-auth profile resolution', () => { expect(process.env.CCS_CODEX_PROFILE).toBe('ck'); expect(process.env.CODEX_HOME).toBe(profileDir); expect(fs.existsSync(path.join(profileDir, 'agents', 'brainstormer.toml'))).toBe(true); + expect(fs.lstatSync(path.join(profileDir, 'plugins', 'cache')).isSymbolicLink()).toBe(true); expect(argv).toEqual(['node', 'codex-runtime', 'default', 'fix failing tests']); }); + it('repairs a positional ccsx profile before delegating to CCS', async () => { + const profileDir = makeProfileDir('ck'); + writeRegistry({ + version: '1.0', + default: null, + profiles: { ck: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null } }, + }); + const events: string[] = []; + + flushRouterCache(); + require.cache[resourcePath] = { + exports: { + ensureCodexProfileResources: (dir: string) => { + expect(dir).toBe(profileDir); + events.push('repair'); + }, + }, + } as NodeJS.Module; + require.cache[ccsPath] = Object.defineProperty({} as NodeJS.Module, 'exports', { + configurable: true, + get: () => { + events.push('ccs'); + return {}; + }, + }); + + const argv = ['node', 'codex-runtime', 'ck']; + const { main } = require(routerPath) as { main: (args: string[]) => Promise }; + const code = await main(argv); + + expect(code).toBe(-1); + expect(events).toEqual(['repair', 'ccs']); + expect(argv).toEqual(['node', 'codex-runtime', 'default']); + }); + it('self-heals missing Codex profile resources during launch', async () => { const profileDir = makeProfileDir('ck'); writeRegistry({ diff --git a/tests/unit/codex-auth/commands/create-command.test.ts b/tests/unit/codex-auth/commands/create-command.test.ts index 39d90e83..042878c7 100644 --- a/tests/unit/codex-auth/commands/create-command.test.ts +++ b/tests/unit/codex-auth/commands/create-command.test.ts @@ -21,6 +21,24 @@ beforeEach(() => { fs.writeFileSync(path.join(homeDir, '.codex', 'agents', 'brainstormer.toml'), 'name = "b"\n'); fs.mkdirSync(path.join(homeDir, '.codex', 'skills'), { recursive: true }); fs.writeFileSync(path.join(homeDir, '.codex', 'skills', 'review.md'), '# Review\n'); + fs.mkdirSync( + path.join(homeDir, '.codex', 'plugins', 'cache', 'claudekit', '1.0.0', 'skills', 'test'), + { recursive: true } + ); + fs.writeFileSync( + path.join( + homeDir, + '.codex', + 'plugins', + 'cache', + 'claudekit', + '1.0.0', + 'skills', + 'test', + 'SKILL.md' + ), + '# Test skill\n' + ); fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true }); process.env.CCS_HOME = ccsHome; spyOn(os, 'homedir').mockReturnValue(homeDir); @@ -96,6 +114,21 @@ describe('handleCreateCodex — happy path', () => { expect(fs.existsSync(instancesDir)).toBe(true); expect(fs.lstatSync(path.join(instancesDir, 'agents')).isSymbolicLink()).toBe(true); expect(fs.lstatSync(path.join(instancesDir, 'skills')).isSymbolicLink()).toBe(true); + expect( + fs.readFileSync( + path.join( + instancesDir, + 'plugins', + 'cache', + 'claudekit', + '1.0.0', + 'skills', + 'test', + 'SKILL.md' + ), + 'utf8' + ) + ).toBe('# Test skill\n'); }); }); From 3103af5355448b9137828b062b50a64f8639902f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Jul 2026 10:22:26 -0400 Subject: [PATCH 3/4] docs(codex-auth): document shared plugin cache --- docs/codex-auth.md | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/codex-auth.md b/docs/codex-auth.md index 4dd2c5eb..d25420ba 100644 --- a/docs/codex-auth.md +++ b/docs/codex-auth.md @@ -10,8 +10,9 @@ refresh in one session overwrites the other's credentials. `ccsx auth` solves this by giving each account its own profile directory under `~/.ccs/codex-instances//`. Each profile holds its own `auth.json` and -`history.jsonl`. Shared `config.toml`, `agents/`, and `skills/` resources are linked -via symlink so model/provider settings and relative agent role config files stay in sync. +`history.jsonl`, plus its own session data. Shared `config.toml`, `agents/`, `skills/`, +and plugin cache resources come from `~/.codex/` so configuration and installed plugin +skills stay in sync across profiles. ## Quick start (4 commands) @@ -149,17 +150,26 @@ No OAuth tokens are ever returned by the API endpoint or shown in the UI. ├── sessions/ # Per-profile chat session dirs (optional) ├── config.toml -> ~/.codex/config.toml (symlink — shared) ├── agents/ -> ~/.codex/agents/ (symlink — shared) - └── skills/ -> ~/.codex/skills/ (symlink — shared) + ├── skills/ -> ~/.codex/skills/ (symlink — shared) + └── plugins/ # Profile-local parent; may hold local metadata + └── cache/ -> ~/.codex/plugins/cache/ (symlink — shared) ~/.codex/ ├── config.toml # Single shared model/provider config ├── agents/ # Shared Codex agent role config files -└── skills/ # Shared Codex skills +├── skills/ # Shared Codex skills +└── plugins/ + └── cache/ # Shared installed plugin payloads ``` -`ccsx auth create ` and `ccsx ` both repair these links idempotently. -This keeps relative Codex config entries such as `agents/foo.toml` valid inside -each isolated `CODEX_HOME`. +Only `plugins/cache/` is shared. The profile's parent `plugins/` directory remains a +real local directory so Codex can keep profile-specific plugin metadata beside the +shared cache. + +`ccsx auth create ` and direct `ccsx ` launches repair these links +idempotently before Codex starts. This keeps relative entries such as +`agents/foo.toml` valid and prevents stale first-launch skill warnings after a plugin +install or update changes the cache. ## Caveats @@ -167,9 +177,10 @@ each isolated `CODEX_HOME`. On Windows, creating symlinks requires Developer Mode or elevated privileges. If symlink creation fails, CCS falls back to copying `config.toml`, `agents/`, -and `skills/`. In this case, changes to `~/.codex/` resources are **not** -automatically reflected in the profile; re-run `ccsx auth create --force` -to refresh the copy. +`skills/`, and the current `plugins/cache/` snapshot. Copies do not update live with +`~/.codex/`; after a plugin update, another profile launch or +`ccsx auth create --force` repair copies newly missing cache entries. Existing +profile-local cache files are preserved. ### Native Codex project-local config warnings From 234dce31500b2b147a0cfe3035722242fea74c40 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Jul 2026 11:36:15 -0400 Subject: [PATCH 4/4] fix(codex-auth): reject aliased plugin cache paths --- src/codex-auth/codex-profile-plugin-cache.ts | 8 ++- .../codex-profile-resources.test.ts | 53 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/codex-auth/codex-profile-plugin-cache.ts b/src/codex-auth/codex-profile-plugin-cache.ts index 432f41fa..f2a32e61 100644 --- a/src/codex-auth/codex-profile-plugin-cache.ts +++ b/src/codex-auth/codex-profile-plugin-cache.ts @@ -1,7 +1,7 @@ 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 { resolveCanonicalPath, symlinkPointsTo } from '../management/shared-manager/fs-helpers'; import { createLogger } from '../services/logging'; const logger = createLogger('codex-auth:resources'); @@ -15,6 +15,12 @@ export function ensureSharedPluginCache(profileDir: string, sharedCodexHome: str ensureProfileLocalPluginsDirectory(pluginsPath); fs.mkdirSync(targetPath, { recursive: true, mode: 0o700 }); + if (resolveCanonicalPath(pluginsPath) === resolveCanonicalPath(path.dirname(targetPath))) { + throw new ConfigError( + 'Refusing plugin cache repair: profile plugins directory resolves to the shared plugins directory.' + ); + } + const existingStat = lstatIfExists(linkPath); if (isExpectedCacheLink(linkPath, targetPath, existingStat)) { return; diff --git a/tests/unit/codex-auth/codex-profile-resources.test.ts b/tests/unit/codex-auth/codex-profile-resources.test.ts index 215bf248..5d80180e 100644 --- a/tests/unit/codex-auth/codex-profile-resources.test.ts +++ b/tests/unit/codex-auth/codex-profile-resources.test.ts @@ -110,6 +110,59 @@ describe('ensureCodexProfileResources', () => { expect(fs.readFileSync(sharedSkillPath, 'utf8')).toBe('# Current shared skill\n'); }); + it('refuses a profile root that resolves to the shared Codex home without changing its cache', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + const sharedPluginsDir = path.join(sharedCodexHome, 'plugins'); + const sharedCacheDir = path.join(sharedPluginsDir, 'cache'); + const sharedMarkerPath = path.join(sharedCacheDir, 'shared-marker.txt'); + fs.writeFileSync(sharedMarkerPath, 'preserve shared cache\n'); + const cacheInodeBeforeRepair = fs.lstatSync(sharedCacheDir).ino; + fs.symlinkSync(sharedCodexHome, profileDir, 'dir'); + + expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow( + 'profile plugins directory resolves to the shared plugins directory' + ); + + expect(fs.lstatSync(profileDir).isSymbolicLink()).toBe(true); + expect(fs.lstatSync(sharedCacheDir).isDirectory()).toBe(true); + expect(fs.lstatSync(sharedCacheDir).isSymbolicLink()).toBe(false); + expect(fs.lstatSync(sharedCacheDir).ino).toBe(cacheInodeBeforeRepair); + expect(fs.readFileSync(sharedMarkerPath, 'utf8')).toBe('preserve shared cache\n'); + expect( + fs.readdirSync(sharedPluginsDir).some((name) => name.startsWith('.cache.ccs-backup-')) + ).toBe(false); + }); + + it('refuses a shared plugins directory that resolves to the profile plugins directory', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + const profilePluginsDir = path.join(profileDir, 'plugins'); + const profileCacheDir = path.join(profilePluginsDir, 'cache'); + const profileMarkerPath = path.join(profileCacheDir, 'profile-marker.txt'); + const sharedPluginsDir = path.join(sharedCodexHome, 'plugins'); + fs.mkdirSync(profileCacheDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(profileMarkerPath, 'preserve profile cache\n'); + const cacheInodeBeforeRepair = fs.lstatSync(profileCacheDir).ino; + fs.rmSync(sharedPluginsDir, { recursive: true, force: true }); + fs.symlinkSync(profilePluginsDir, sharedPluginsDir, 'dir'); + + expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow( + 'profile plugins directory resolves to the shared plugins directory' + ); + + expect(fs.lstatSync(sharedPluginsDir).isSymbolicLink()).toBe(true); + expect(fs.lstatSync(profileCacheDir).isDirectory()).toBe(true); + expect(fs.lstatSync(profileCacheDir).isSymbolicLink()).toBe(false); + expect(fs.lstatSync(profileCacheDir).ino).toBe(cacheInodeBeforeRepair); + expect(fs.readFileSync(profileMarkerPath, 'utf8')).toBe('preserve profile cache\n'); + expect( + fs.readdirSync(profilePluginsDir).some((name) => name.startsWith('.cache.ccs-backup-')) + ).toBe(false); + }); + it('keeps the plugin cache projection stable across repeated repairs', async () => { const { ensureCodexProfileResources } = await import( '../../../src/codex-auth/codex-profile-resources'