mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-06 14:14:20 +00:00
Merge pull request #1640 from kaitranntt/kai/fix/1639-ccsx-profile-plugin-cache-v2
fix(codex-auth): share plugin cache across profiles
This commit is contained in:
6 files changed
+768
-11
No files matched your search
+21
-10
@@ -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/<name>/`. 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 <name>` and `ccsx <name>` 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 <name>` and direct `ccsx <name>` 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 <name> --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 <name> --force` repair copies newly missing cache entries. Existing
|
||||
profile-local cache files are preserved.
|
||||
|
||||
### Native Codex project-local config warnings
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { ConfigError } from '../errors/error-types';
|
||||
import { resolveCanonicalPath, 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 });
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<number> };
|
||||
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({
|
||||
|
||||
@@ -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,344 @@ 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('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'
|
||||
);
|
||||
|
||||
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<typeof fs.symlinkSync>) => {
|
||||
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<typeof fs.renameSync>) => {
|
||||
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<typeof fs.symlinkSync>) => {
|
||||
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<typeof fs.symlinkSync>) => {
|
||||
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<typeof fs.mkdirSync>) => {
|
||||
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<typeof fs.symlinkSync>) => {
|
||||
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<typeof fs.mkdirSync>) => {
|
||||
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 +472,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<typeof fs.symlinkSync>) => {
|
||||
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<typeof fs.symlinkSync>) => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user