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'