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",