diff --git a/.codex-plugin/README.md b/.codex-plugin/README.md
index 7d4c5a19..6cc75138 100644
--- a/.codex-plugin/README.md
+++ b/.codex-plugin/README.md
@@ -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
`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
> install work with this layout, but runtime skill loading from local/repo
> marketplaces is unreliable upstream
diff --git a/README.md b/README.md
index cf14ff8a..c945f3b8 100644
--- a/README.md
+++ b/README.md
@@ -1393,10 +1393,15 @@ The repo also exposes a Codex repo-scoped marketplace (`.agents/plugins/marketpl
```bash
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
diff --git a/package.json b/package.json
index cca10388..a1c87a90 100644
--- a/package.json
+++ b/package.json
@@ -81,6 +81,7 @@
"scripts/auto-update.js",
"scripts/claw.js",
"scripts/control-pane.js",
+ "scripts/codex/check-plugin-cache.js",
"scripts/codex/merge-codex-config.js",
"scripts/codex/merge-mcp-config.js",
"scripts/discussion-audit.js",
diff --git a/plugins/ecc/README.md b/plugins/ecc/README.md
index c14d42d0..a4433f2f 100644
--- a/plugins/ecc/README.md
+++ b/plugins/ecc/README.md
@@ -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)
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
manual sync flow documented in the README:
diff --git a/scripts/codex/check-plugin-cache.js b/scripts/codex/check-plugin-cache.js
new file mode 100644
index 00000000..6839b413
--- /dev/null
+++ b/scripts/codex/check-plugin-cache.js
@@ -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
Override CODEX_HOME (default: $CODEX_HOME or ~/.codex)',
+ ' --plugin-dir Check a specific installed plugin cache directory',
+ ' --marketplace Marketplace cache name (default: ecc)',
+ ' --plugin Plugin cache name (default: ecc)',
+ ' --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 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();
diff --git a/tests/plugin-manifest.test.js b/tests/plugin-manifest.test.js
index 985d8521..0121cfb7 100644
--- a/tests/plugin-manifest.test.js
+++ b/tests/plugin-manifest.test.js
@@ -463,6 +463,7 @@ test('plugins/ecc README documents the upstream Codex fragility', () => {
assert.ok(fs.existsSync(readmePath), 'Expected plugins/ecc/README.md');
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('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');
});
diff --git a/tests/scripts/codex-hooks.test.js b/tests/scripts/codex-hooks.test.js
index d482bb32..1c49f4c6 100644
--- a/tests/scripts/codex-hooks.test.js
+++ b/tests/scripts/codex-hooks.test.js
@@ -11,9 +11,12 @@ const TOML = require('@iarna/toml');
const repoRoot = path.join(__dirname, '..', '..');
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 mergeMcpConfigScript = path.join(repoRoot, 'scripts', 'codex', 'merge-mcp-config.js');
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 = {
CLAUDE_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 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', ' \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,
// so the quoted-path shell-injection test is only meaningful on Unix.
if (os.platform() === 'win32') {
diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js
index e998b3b0..8e33635d 100644
--- a/tests/scripts/npm-publish-surface.test.js
+++ b/tests/scripts/npm-publish-surface.test.js
@@ -69,6 +69,7 @@ function buildExpectedPublishPaths(repoRoot) {
"scripts/session-inspect.js",
"scripts/uninstall.js",
"scripts/gemini-adapt-agents.js",
+ "scripts/codex/check-plugin-cache.js",
"scripts/codex/merge-codex-config.js",
"scripts/codex/merge-mcp-config.js",
".codex-plugin",
@@ -141,6 +142,7 @@ function main() {
"scripts/release-video-suite.js",
"scripts/work-items.js",
"scripts/platform-audit.js",
+ "scripts/codex/check-plugin-cache.js",
".gemini/GEMINI.md",
".qwen/QWEN.md",
".claude-plugin/plugin.json",