fix: add plugin cache health check (#2249)

* fix: add plugin cache health check

* fix: harden plugin cache diagnostics

* fix: reject escaping plugin cache refs

* test: remove unused plugin cache fixture
This commit is contained in:
mehmet turac 2026-06-15 21:01:25 +03:00 committed by GitHub
parent 48608863ea
commit 683d291aa3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 465 additions and 2 deletions

View File

@ -38,6 +38,14 @@ references the root `skills/` and `.mcp.json` so content stays single-sourced.
After adding or updating the marketplace, restart Codex and install or enable After adding or updating the marketplace, restart Codex and install or enable
`ecc` from the plugin directory. `ecc` from the plugin directory.
After install, `codex plugin list` is only a registration check. From an ECC
checkout, run the cache check to verify that the installed manifest can resolve
its referenced skills, MCP config, and assets:
```bash
node scripts/codex/check-plugin-cache.js
```
> **Plugin mode is currently fragile on Codex.** Marketplace discovery and > **Plugin mode is currently fragile on Codex.** Marketplace discovery and
> install work with this layout, but runtime skill loading from local/repo > install work with this layout, but runtime skill loading from local/repo
> marketplaces is unreliable upstream > marketplaces is unreliable upstream

View File

@ -1393,10 +1393,15 @@ The repo also exposes a Codex repo-scoped marketplace (`.agents/plugins/marketpl
```bash ```bash
codex plugin marketplace add affaan-m/ECC codex plugin marketplace add affaan-m/ECC
codex plugin list # ecc@ecc should appear codex plugin list
node scripts/codex/check-plugin-cache.js
``` ```
**Plugin mode is currently fragile on Codex.** Marketplace discovery and install work with this layout, but runtime skill loading from local/repo marketplaces is still unreliable upstream ([openai/codex#26037](https://github.com/openai/codex/issues/26037)): Codex copies only the plugin folder into its install cache, so plugins that reference shared repo content may not expose skills in a fresh session. Until that settles, treat the plugin path as experimental and prefer the manual sync flow above (`scripts/sync-ecc-to-codex.sh`), which is the supported Codex route. See [#2128](https://github.com/affaan-m/ECC/issues/2128) for the full investigation. `codex plugin list` only confirms marketplace registration. Run
`node scripts/codex/check-plugin-cache.js` after install to verify that the
installed cache can resolve the manifest's skills, MCP config, and assets.
**Plugin mode is currently fragile on Codex.** Marketplace discovery and install work with this layout, but runtime skill loading from local/repo marketplaces is still unreliable upstream ([openai/codex#26037](https://github.com/openai/codex/issues/26037)): Codex copies only the plugin folder into its install cache, so plugins that reference shared repo content may not expose skills in a fresh session. If the cache health check reports missing manifest references, treat the plugin path as discovery-only and prefer the manual sync flow above (`scripts/sync-ecc-to-codex.sh`), which is the supported Codex route. See [#2128](https://github.com/affaan-m/ECC/issues/2128) for the full investigation.
### What's Included ### What's Included

View File

@ -81,6 +81,7 @@
"scripts/auto-update.js", "scripts/auto-update.js",
"scripts/claw.js", "scripts/claw.js",
"scripts/control-pane.js", "scripts/control-pane.js",
"scripts/codex/check-plugin-cache.js",
"scripts/codex/merge-codex-config.js", "scripts/codex/merge-codex-config.js",
"scripts/codex/merge-mcp-config.js", "scripts/codex/merge-mcp-config.js",
"scripts/discussion-audit.js", "scripts/discussion-audit.js",

View File

@ -33,6 +33,17 @@ cache, and local/personal marketplace plugins are not always exposed at
runtime (see [openai/codex#26037](https://github.com/openai/codex/issues/26037) runtime (see [openai/codex#26037](https://github.com/openai/codex/issues/26037)
and [affaan-m/ECC#2128](https://github.com/affaan-m/ECC/issues/2128)). and [affaan-m/ECC#2128](https://github.com/affaan-m/ECC/issues/2128)).
After install, `codex plugin list` is not enough to prove the runtime can load
the referenced skills and assets. From an ECC checkout, run:
```bash
node scripts/codex/check-plugin-cache.js
```
The check inspects the installed cache under `CODEX_HOME` (or `~/.codex`) and
fails if `.codex-plugin/plugin.json` points at files that were not copied into
that cache entry.
Until the upstream discovery issues settle, the supported Codex path is the Until the upstream discovery issues settle, the supported Codex path is the
manual sync flow documented in the README: manual sync flow documented in the README:

View File

@ -0,0 +1,264 @@
#!/usr/bin/env node
'use strict';
/**
* Verify that the installed Codex plugin cache can resolve every file path
* referenced by the cached plugin manifest.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const REPO_ROOT = path.join(__dirname, '..', '..');
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8'));
function usage() {
console.log([
'Usage: check-plugin-cache.js [options]',
'',
'Options:',
' --codex-home <dir> Override CODEX_HOME (default: $CODEX_HOME or ~/.codex)',
' --plugin-dir <dir> Check a specific installed plugin cache directory',
' --marketplace <name> Marketplace cache name (default: ecc)',
' --plugin <name> Plugin cache name (default: ecc)',
' --version <version> Plugin version (default: package.json version)',
' --help Show this help text',
].join('\n'));
}
function validateCacheSegment(flag, value) {
if (
typeof value !== 'string' ||
value.trim() === '' ||
value.includes('\0') ||
value.includes('..') ||
value.includes('/') ||
value.includes('\\') ||
path.isAbsolute(value) ||
path.win32.isAbsolute(value)
) {
throw new Error(`Invalid ${flag}: expected a single cache path segment`);
}
return value;
}
function parseArgs(argv) {
const defaults = {
marketplace: 'ecc',
plugin: 'ecc',
version: PACKAGE_JSON.version,
codexHome: process.env.CODEX_HOME || path.join(os.homedir(), '.codex'),
pluginDir: null,
};
const optionKeys = {
'--codex-home': 'codexHome',
'--plugin-dir': 'pluginDir',
'--marketplace': 'marketplace',
'--plugin': 'plugin',
'--version': 'version',
};
let parsed = {};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--help' || arg === '-h') {
parsed = { ...parsed, help: true };
continue;
}
const key = optionKeys[arg];
if (!key) {
throw new Error(`Unknown argument: ${arg}`);
}
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`Missing value for ${arg}`);
}
index += 1;
parsed = { ...parsed, [key]: value };
}
const options = { ...defaults, ...parsed };
return {
...options,
marketplace: validateCacheSegment('--marketplace', options.marketplace),
plugin: validateCacheSegment('--plugin', options.plugin),
version: validateCacheSegment('--version', options.version),
codexHome: path.resolve(options.codexHome),
pluginDir: options.pluginDir ? path.resolve(options.pluginDir) : null,
};
}
function log(message) {
console.log(`[ecc-codex] ${message}`);
}
function readJson(filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
throw new Error(`Failed to read ${filePath}: ${error.message}`);
}
}
function pluginCacheDir(options) {
if (options.pluginDir) {
return options.pluginDir;
}
return path.join(
options.codexHome,
'plugins',
'cache',
options.marketplace,
options.plugin,
options.version
);
}
function listInstalledVersions(options) {
const versionsRoot = path.join(
options.codexHome,
'plugins',
'cache',
options.marketplace,
options.plugin
);
try {
return fs.readdirSync(versionsRoot, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
.sort();
} catch {
return [];
}
}
function manifestPathFor(pluginDir) {
return path.join(pluginDir, '.codex-plugin', 'plugin.json');
}
function collectManifestRefs(manifest) {
const refs = [];
if (typeof manifest.skills === 'string') {
refs.push({ label: 'skills', ref: manifest.skills, kind: 'directory' });
}
if (typeof manifest.mcpServers === 'string') {
refs.push({ label: 'mcpServers', ref: manifest.mcpServers, kind: 'file' });
}
if (manifest.interface && typeof manifest.interface.composerIcon === 'string') {
refs.push({
label: 'interface.composerIcon',
ref: manifest.interface.composerIcon,
kind: 'file',
});
}
if (manifest.interface && typeof manifest.interface.logo === 'string') {
refs.push({ label: 'interface.logo', ref: manifest.interface.logo, kind: 'file' });
}
return refs;
}
function pathExists(target, kind) {
try {
const stat = fs.statSync(target);
return kind === 'directory' ? stat.isDirectory() : stat.isFile();
} catch {
return false;
}
}
function checkCache(options) {
const cacheDir = pluginCacheDir(options);
const manifestPath = manifestPathFor(cacheDir);
log('Codex plugin cache check');
log(`Codex home: ${options.codexHome}`);
log(`Plugin cache: ${cacheDir}`);
if (!fs.existsSync(manifestPath)) {
const versions = listInstalledVersions(options);
log(`[FAIL] Cached plugin manifest missing: ${manifestPath}`);
if (versions.length > 0) {
log(`Installed versions found: ${versions.join(', ')}`);
log(`Re-run with --version <version> if you want to inspect a different cache entry.`);
} else {
log(`No installed cache entries found for ${options.marketplace}/${options.plugin}.`);
if (options.marketplace === 'ecc' && options.plugin === 'ecc') {
log('Run: codex plugin marketplace add affaan-m/ECC');
} else {
log('Install the requested plugin into the Codex plugin cache.');
}
log('Then run: codex plugin list');
}
return 1;
}
const manifest = readJson(manifestPath);
const refs = collectManifestRefs(manifest);
let failures = 0;
log(`Manifest: ${manifestPath}`);
for (const entry of refs) {
const target = path.resolve(cacheDir, entry.ref);
const relativeTarget = path.relative(cacheDir, target);
if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
failures += 1;
log(`[FAIL] ${entry.label} escapes cache boundary`);
continue;
}
if (pathExists(target, entry.kind)) {
log(`[OK] ${entry.label} -> ${target}`);
} else {
failures += 1;
log(`[FAIL] ${entry.label} missing -> ${target}`);
}
}
if (refs.length === 0) {
log('[WARN] Cached manifest has no string path references to verify.');
}
if (failures > 0) {
log(`${failures} cached manifest reference(s) do not resolve.`);
log('codex plugin list only confirms marketplace registration; it is not proof of runtime skill loading.');
const syncScript = path.join(REPO_ROOT, 'scripts', 'sync-ecc-to-codex.sh');
if (fs.existsSync(syncScript)) {
log('Use the supported sync path until the cache contains the referenced files:');
log('npm install && bash scripts/sync-ecc-to-codex.sh');
} else {
log('Use the supported manual sync workflow from your ECC installation.');
}
return 1;
}
log('All cached manifest references resolve.');
return 0;
}
function main() {
let options;
try {
options = parseArgs(process.argv.slice(2));
} catch (error) {
console.error(`[ecc-codex] ${error.message}`);
usage();
process.exit(1);
}
if (options.help) {
usage();
process.exit(0);
}
try {
process.exit(checkCache(options));
} catch (error) {
console.error(`[ecc-codex] ${error.message}`);
process.exit(1);
}
}
main();

View File

@ -463,6 +463,7 @@ test('plugins/ecc README documents the upstream Codex fragility', () => {
assert.ok(fs.existsSync(readmePath), 'Expected plugins/ecc/README.md'); assert.ok(fs.existsSync(readmePath), 'Expected plugins/ecc/README.md');
const source = fs.readFileSync(readmePath, 'utf8'); const source = fs.readFileSync(readmePath, 'utf8');
assert.ok(source.includes('openai/codex'), 'plugins/ecc README must link the upstream Codex discovery issue'); assert.ok(source.includes('openai/codex'), 'plugins/ecc README must link the upstream Codex discovery issue');
assert.ok(source.includes('check-plugin-cache.js'), 'plugins/ecc README must point at the cache health check');
assert.ok(source.includes('sync-ecc-to-codex.sh'), 'plugins/ecc README must point at the supported manual sync flow'); assert.ok(source.includes('sync-ecc-to-codex.sh'), 'plugins/ecc README must point at the supported manual sync flow');
}); });

View File

@ -11,9 +11,12 @@ const TOML = require('@iarna/toml');
const repoRoot = path.join(__dirname, '..', '..'); const repoRoot = path.join(__dirname, '..', '..');
const installScript = path.join(repoRoot, 'scripts', 'codex', 'install-global-git-hooks.sh'); const installScript = path.join(repoRoot, 'scripts', 'codex', 'install-global-git-hooks.sh');
const pluginCacheCheckScript = path.join(repoRoot, 'scripts', 'codex', 'check-plugin-cache.js');
const mergeCodexConfigScript = path.join(repoRoot, 'scripts', 'codex', 'merge-codex-config.js'); const mergeCodexConfigScript = path.join(repoRoot, 'scripts', 'codex', 'merge-codex-config.js');
const mergeMcpConfigScript = path.join(repoRoot, 'scripts', 'codex', 'merge-mcp-config.js'); const mergeMcpConfigScript = path.join(repoRoot, 'scripts', 'codex', 'merge-mcp-config.js');
const syncScript = path.join(repoRoot, 'scripts', 'sync-ecc-to-codex.sh'); const syncScript = path.join(repoRoot, 'scripts', 'sync-ecc-to-codex.sh');
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
const packageVersion = packageJson.version;
const deterministicPackageEnv = { const deterministicPackageEnv = {
CLAUDE_PACKAGE_MANAGER: 'npm', CLAUDE_PACKAGE_MANAGER: 'npm',
CLAUDE_CODE_PACKAGE_MANAGER: 'npm', CLAUDE_CODE_PACKAGE_MANAGER: 'npm',
@ -82,9 +85,177 @@ function makeHermeticCodexEnv(homeDir, codexDir, extraEnv = {}) {
}; };
} }
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
function seedPluginCache(codexDir, manifest, files = []) {
const cacheDir = path.join(codexDir, 'plugins', 'cache', 'ecc', 'ecc', packageVersion);
writeJson(path.join(cacheDir, '.codex-plugin', 'plugin.json'), manifest);
fs.writeFileSync(path.join(cacheDir, 'README.md'), '# cached plugin\n');
for (const [relativePath, content] of files) {
const target = path.join(cacheDir, relativePath);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, content);
}
return cacheDir;
}
const cacheManifestWithLocalRefs = {
name: 'ecc',
version: packageVersion,
skills: './skills/',
mcpServers: './.mcp.json',
interface: {
composerIcon: './assets/ecc-icon.svg',
logo: './assets/hero.png',
},
};
let passed = 0; let passed = 0;
let failed = 0; let failed = 0;
if (
test('check-plugin-cache fails when the installed cache is missing manifest-referenced files', () => {
const homeDir = createTempDir('codex-plugin-cache-home-');
const codexDir = path.join(homeDir, '.codex');
try {
seedPluginCache(codexDir, cacheManifestWithLocalRefs);
const result = runNode(pluginCacheCheckScript, [], makeHermeticCodexEnv(homeDir, codexDir));
assert.strictEqual(result.status, 1, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /Plugin cache:/);
assert.match(result.stdout, /\[FAIL\] skills missing/);
assert.match(result.stdout, /\[FAIL\] mcpServers missing/);
assert.match(result.stdout, /codex plugin list only confirms marketplace registration/);
assert.match(result.stdout, /sync-ecc-to-codex\.sh/);
} finally {
cleanup(homeDir);
}
})
)
passed++;
else failed++;
if (
test('check-plugin-cache rejects manifest references that escape the cache boundary', () => {
const homeDir = createTempDir('codex-plugin-cache-manifest-traversal-home-');
const codexDir = path.join(homeDir, '.codex');
try {
seedPluginCache(codexDir, {
name: 'ecc',
version: packageVersion,
skills: '../../../../../etc/passwd',
mcpServers: '../../.mcp.json',
});
const result = runNode(pluginCacheCheckScript, [], makeHermeticCodexEnv(homeDir, codexDir));
assert.strictEqual(result.status, 1, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /\[FAIL\] skills escapes cache boundary/);
assert.match(result.stdout, /\[FAIL\] mcpServers escapes cache boundary/);
assert.doesNotMatch(result.stdout, /etc\/passwd/);
} finally {
cleanup(homeDir);
}
})
)
passed++;
else failed++;
if (
test('check-plugin-cache passes when cached manifest references resolve inside the cache', () => {
const homeDir = createTempDir('codex-plugin-cache-ok-home-');
const codexDir = path.join(homeDir, '.codex');
try {
const cacheDir = seedPluginCache(codexDir, cacheManifestWithLocalRefs, [
['.mcp.json', '{"mcpServers":{}}\n'],
['assets/ecc-icon.svg', '<svg />\n'],
['assets/hero.png', 'png\n'],
]);
fs.mkdirSync(path.join(cacheDir, 'skills'), { recursive: true });
const result = runNode(pluginCacheCheckScript, [], makeHermeticCodexEnv(homeDir, codexDir));
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /\[OK\] skills/);
assert.match(result.stdout, /\[OK\] mcpServers/);
assert.match(result.stdout, /All cached manifest references resolve/);
} finally {
cleanup(homeDir);
}
})
)
passed++;
else failed++;
if (
test('check-plugin-cache reports a missing installed cache clearly', () => {
const homeDir = createTempDir('codex-plugin-cache-missing-home-');
const codexDir = path.join(homeDir, '.codex');
try {
const result = runNode(pluginCacheCheckScript, [], makeHermeticCodexEnv(homeDir, codexDir));
assert.strictEqual(result.status, 1, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /Cached plugin manifest missing/);
assert.match(result.stdout, /codex plugin marketplace add affaan-m\/ECC/);
} finally {
cleanup(homeDir);
}
})
)
passed++;
else failed++;
if (
test('check-plugin-cache rejects traversal in cache path segments', () => {
const homeDir = createTempDir('codex-plugin-cache-traversal-home-');
const codexDir = path.join(homeDir, '.codex');
try {
const result = runNode(
pluginCacheCheckScript,
['--marketplace', '../outside'],
makeHermeticCodexEnv(homeDir, codexDir)
);
assert.strictEqual(result.status, 1, `${result.stdout}\n${result.stderr}`);
assert.match(result.stderr, /Invalid --marketplace/);
} finally {
cleanup(homeDir);
}
})
)
passed++;
else failed++;
if (
test('check-plugin-cache names custom missing cache entries in diagnostics', () => {
const homeDir = createTempDir('codex-plugin-cache-custom-home-');
const codexDir = path.join(homeDir, '.codex');
try {
const result = runNode(
pluginCacheCheckScript,
['--marketplace', 'custom-market', '--plugin', 'custom-plugin', '--version', '1.2.3'],
makeHermeticCodexEnv(homeDir, codexDir)
);
assert.strictEqual(result.status, 1, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /No installed cache entries found for custom-market\/custom-plugin/);
assert.match(result.stdout, /Install the requested plugin into the Codex plugin cache/);
} finally {
cleanup(homeDir);
}
})
)
passed++;
else failed++;
// Windows NTFS does not allow double-quote characters in file paths, // Windows NTFS does not allow double-quote characters in file paths,
// so the quoted-path shell-injection test is only meaningful on Unix. // so the quoted-path shell-injection test is only meaningful on Unix.
if (os.platform() === 'win32') { if (os.platform() === 'win32') {

View File

@ -69,6 +69,7 @@ function buildExpectedPublishPaths(repoRoot) {
"scripts/session-inspect.js", "scripts/session-inspect.js",
"scripts/uninstall.js", "scripts/uninstall.js",
"scripts/gemini-adapt-agents.js", "scripts/gemini-adapt-agents.js",
"scripts/codex/check-plugin-cache.js",
"scripts/codex/merge-codex-config.js", "scripts/codex/merge-codex-config.js",
"scripts/codex/merge-mcp-config.js", "scripts/codex/merge-mcp-config.js",
".codex-plugin", ".codex-plugin",
@ -141,6 +142,7 @@ function main() {
"scripts/release-video-suite.js", "scripts/release-video-suite.js",
"scripts/work-items.js", "scripts/work-items.js",
"scripts/platform-audit.js", "scripts/platform-audit.js",
"scripts/codex/check-plugin-cache.js",
".gemini/GEMINI.md", ".gemini/GEMINI.md",
".qwen/QWEN.md", ".qwen/QWEN.md",
".claude-plugin/plugin.json", ".claude-plugin/plugin.json",