diff --git a/CHANGELOG.md b/CHANGELOG.md index 715eed2..6e5710d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### Fixes +- Fixed the Codex plugin MCP bootstrap on Windows: it no longer depends on an + unexpanded `${PLUGIN_ROOT}` argument or a sandbox-denied Node realpath walk. + The installed plugin is resolved from Codex's cache, the newest matching + cache entry is selected, and the bundled server is loaded in-process. - Made repeated spec-intake discovery durably idempotent for implementation- detail facts, so answering several product questions and approving a large intake no longer re-appends the same facts until the mission bound is hit. diff --git a/integrations/codex-plugin/specbridge/.mcp.json b/integrations/codex-plugin/specbridge/.mcp.json index 227cc1f..df2e9d8 100644 --- a/integrations/codex-plugin/specbridge/.mcp.json +++ b/integrations/codex-plugin/specbridge/.mcp.json @@ -3,7 +3,8 @@ "specbridge": { "command": "node", "args": [ - "${PLUGIN_ROOT}/dist/mcp-launcher.cjs" + "-e", + "const fs=require('node:fs'),path=require('node:path'),os=require('node:os'),Module=require('node:module'),cache=path.resolve(process.env.SPECBRIDGE_PLUGIN_CACHE_ROOT||path.join(process.env.CODEX_HOME||path.join(os.homedir(),'.codex'),'plugins','cache')),source=process._eval,candidates=[];if(fs.existsSync(cache))for(const marketplace of fs.readdirSync(cache,{withFileTypes:true})){if(!marketplace.isDirectory())continue;const pluginDir=path.join(cache,marketplace.name,'specbridge');if(!fs.existsSync(pluginDir))continue;for(const version of fs.readdirSync(pluginDir,{withFileTypes:true})){if(!version.isDirectory())continue;const root=path.join(pluginDir,version.name),manifestPath=path.join(root,'.codex-plugin','plugin.json'),configPath=path.join(root,'.mcp.json');try{const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8')),config=JSON.parse(fs.readFileSync(configPath,'utf8')),server=(config.mcpServers||config.mcp_servers||{}).specbridge,installed=fs.statSync(root);if(manifest.name==='specbridge'&&server?.command==='node'&&server.args?.[1]===source)candidates.push({root,installedAt:Math.max(installed.birthtimeMs,installed.mtimeMs)});}catch{}}}candidates.sort((a,b)=>b.installedAt-a.installedAt);if(candidates.length===0)throw new Error('The installed SpecBridge plugin could not be located in the Codex plugin cache');const file=path.join(candidates[0].root,'dist','mcp-launcher.cjs'),loaded=new Module(file);loaded.filename=file;loaded.paths=Module._nodeModulePaths(path.dirname(file));loaded._compile(fs.readFileSync(file,'utf8'),file);" ], "enabled": true, "startup_timeout_sec": 15, diff --git a/integrations/codex-plugin/specbridge/dist/checksums.json b/integrations/codex-plugin/specbridge/dist/checksums.json index 29e5a8d..5821889 100644 --- a/integrations/codex-plugin/specbridge/dist/checksums.json +++ b/integrations/codex-plugin/specbridge/dist/checksums.json @@ -11,8 +11,8 @@ "bytes": 5858878 }, "mcp-launcher.cjs": { - "sha256": "205d2730178bcdb5a06e199a42a6a6fb0977f42d706f3d445aec801a60874b3e", - "bytes": 4567 + "sha256": "5ad0dca58e1f0195ba8819144d44c5285bf43a360090e681594df050899c7770", + "bytes": 4178 }, "mcp-server.cjs": { "sha256": "090a7686c19a94495baa75b230899f15a8e0b6036cfb09f73d4482686b621a3e", diff --git a/integrations/codex-plugin/specbridge/dist/mcp-launcher.cjs b/integrations/codex-plugin/specbridge/dist/mcp-launcher.cjs index e31f457..ca9526d 100644 --- a/integrations/codex-plugin/specbridge/dist/mcp-launcher.cjs +++ b/integrations/codex-plugin/specbridge/dist/mcp-launcher.cjs @@ -1,19 +1,20 @@ /* * Codex plugin MCP launcher. * - * Codex expands ${PLUGIN_ROOT} to locate this file, while the child process - * inherits the active task's working directory. This launcher keeps those - * two roots separate: it resolves the user's project, then starts the shared - * SpecBridge MCP bundle with an argv array and inherited stdio. No shell is - * involved, so Windows paths and paths containing spaces stay intact. + * Codex starts this file through the cache-locating bootstrap in .mcp.json, + * while the MCP process inherits the active task's working directory. This + * launcher keeps those roots separate: it resolves the user's project, then + * loads the shared SpecBridge MCP bundle in the current process. Loading from + * source text is intentional: Windows Codex sandboxes can read an installed + * plugin file but deny Node's realpath walk over its user-profile parents. */ /* eslint-disable @typescript-eslint/no-require-imports */ /* global require, __dirname, process */ 'use strict'; -const { existsSync, realpathSync, statSync } = require('node:fs'); +const { existsSync, readFileSync, realpathSync, statSync } = require('node:fs'); +const Module = require('node:module'); const path = require('node:path'); -const { spawn } = require('node:child_process'); const pluginRoot = path.resolve(__dirname, '..'); const serverBundle = path.join(__dirname, 'mcp-server.cjs'); @@ -93,6 +94,13 @@ function resolveProjectRoot() { return undefined; } +function loadCommonJs(entryPath) { + const loaded = new Module(entryPath); + loaded.filename = entryPath; + loaded.paths = Module._nodeModulePaths(path.dirname(entryPath)); + loaded._compile(readFileSync(entryPath, 'utf8'), entryPath); +} + if (!existsSync(serverBundle)) { diagnostic( 'bundle_missing', @@ -105,47 +113,17 @@ if (!existsSync(serverBundle)) { if (projectRoot === undefined) { process.exitCode = 1; } else { - const child = spawn( - process.execPath, - [serverBundle, '--stdio', '--project-root', projectRoot], - { - cwd: projectRoot, - env: process.env, - stdio: 'inherit', - shell: false, - windowsHide: true, - }, - ); - - let finished = false; - child.once('error', (cause) => { - if (finished) return; - finished = true; + try { + process.chdir(projectRoot); + process.argv = [process.execPath, serverBundle, '--stdio', '--project-root', projectRoot]; + loadCommonJs(serverBundle); + } catch (cause) { diagnostic( - 'server_spawn_failed', + 'server_load_failed', 'The bundled SpecBridge MCP server could not be started.', cause instanceof Error ? cause.message : String(cause), ); process.exitCode = 1; - }); - child.once('exit', (code, signal) => { - if (finished) return; - finished = true; - if (signal !== null && process.platform !== 'win32') { - try { - process.kill(process.pid, signal); - return; - } catch { - // Fall through to a non-zero exit when signal propagation fails. - } - } - process.exitCode = code ?? 1; - }); - - for (const signal of ['SIGINT', 'SIGTERM']) { - process.on(signal, () => { - if (!finished) child.kill(signal); - }); } } } diff --git a/integrations/codex-plugin/src/mcp-launcher.cjs b/integrations/codex-plugin/src/mcp-launcher.cjs index e31f457..ca9526d 100644 --- a/integrations/codex-plugin/src/mcp-launcher.cjs +++ b/integrations/codex-plugin/src/mcp-launcher.cjs @@ -1,19 +1,20 @@ /* * Codex plugin MCP launcher. * - * Codex expands ${PLUGIN_ROOT} to locate this file, while the child process - * inherits the active task's working directory. This launcher keeps those - * two roots separate: it resolves the user's project, then starts the shared - * SpecBridge MCP bundle with an argv array and inherited stdio. No shell is - * involved, so Windows paths and paths containing spaces stay intact. + * Codex starts this file through the cache-locating bootstrap in .mcp.json, + * while the MCP process inherits the active task's working directory. This + * launcher keeps those roots separate: it resolves the user's project, then + * loads the shared SpecBridge MCP bundle in the current process. Loading from + * source text is intentional: Windows Codex sandboxes can read an installed + * plugin file but deny Node's realpath walk over its user-profile parents. */ /* eslint-disable @typescript-eslint/no-require-imports */ /* global require, __dirname, process */ 'use strict'; -const { existsSync, realpathSync, statSync } = require('node:fs'); +const { existsSync, readFileSync, realpathSync, statSync } = require('node:fs'); +const Module = require('node:module'); const path = require('node:path'); -const { spawn } = require('node:child_process'); const pluginRoot = path.resolve(__dirname, '..'); const serverBundle = path.join(__dirname, 'mcp-server.cjs'); @@ -93,6 +94,13 @@ function resolveProjectRoot() { return undefined; } +function loadCommonJs(entryPath) { + const loaded = new Module(entryPath); + loaded.filename = entryPath; + loaded.paths = Module._nodeModulePaths(path.dirname(entryPath)); + loaded._compile(readFileSync(entryPath, 'utf8'), entryPath); +} + if (!existsSync(serverBundle)) { diagnostic( 'bundle_missing', @@ -105,47 +113,17 @@ if (!existsSync(serverBundle)) { if (projectRoot === undefined) { process.exitCode = 1; } else { - const child = spawn( - process.execPath, - [serverBundle, '--stdio', '--project-root', projectRoot], - { - cwd: projectRoot, - env: process.env, - stdio: 'inherit', - shell: false, - windowsHide: true, - }, - ); - - let finished = false; - child.once('error', (cause) => { - if (finished) return; - finished = true; + try { + process.chdir(projectRoot); + process.argv = [process.execPath, serverBundle, '--stdio', '--project-root', projectRoot]; + loadCommonJs(serverBundle); + } catch (cause) { diagnostic( - 'server_spawn_failed', + 'server_load_failed', 'The bundled SpecBridge MCP server could not be started.', cause instanceof Error ? cause.message : String(cause), ); process.exitCode = 1; - }); - child.once('exit', (code, signal) => { - if (finished) return; - finished = true; - if (signal !== null && process.platform !== 'win32') { - try { - process.kill(process.pid, signal); - return; - } catch { - // Fall through to a non-zero exit when signal propagation fails. - } - } - process.exitCode = code ?? 1; - }); - - for (const signal of ['SIGINT', 'SIGTERM']) { - process.on(signal, () => { - if (!finished) child.kill(signal); - }); } } } diff --git a/scripts/validate-codex-plugin.mjs b/scripts/validate-codex-plugin.mjs index d4476b3..a97eb03 100644 --- a/scripts/validate-codex-plugin.mjs +++ b/scripts/validate-codex-plugin.mjs @@ -107,8 +107,18 @@ check(server !== undefined, '.mcp.json must define mcpServers.specbridge'); if (server !== undefined) { check(server.command === 'node', 'Codex MCP command must be node'); check( - Array.isArray(server.args) && server.args.length === 1 && server.args[0] === '${PLUGIN_ROOT}/dist/mcp-launcher.cjs', - 'Codex MCP must launch ${PLUGIN_ROOT}/dist/mcp-launcher.cjs as one argv item', + Array.isArray(server.args) && + server.args.length === 2 && + server.args[0] === '-e' && + server.args[1].includes('SPECBRIDGE_PLUGIN_CACHE_ROOT') && + server.args[1].includes('process._eval') && + server.args[1].includes("manifest.name==='specbridge'") && + server.args[1].includes('installed.birthtimeMs') && + server.args[1].includes('b.installedAt-a.installedAt') && + server.args[1].includes("readFileSync(file,'utf8')") && + server.args[1].includes('loaded._compile(') && + !server.args.some((arg) => arg.includes('${PLUGIN_ROOT}')), + 'Codex MCP must locate its matching installed cache entry and memory-load the launcher without placeholder or Node realpath walks', ); check(server.cwd === undefined, 'Codex MCP config must not force cwd to the plugin cache'); check(server.env === undefined, 'Codex MCP config must not inject environment values'); diff --git a/scripts/verify-codex-plugin-bundle.mjs b/scripts/verify-codex-plugin-bundle.mjs index 0574864..d36dfc1 100644 --- a/scripts/verify-codex-plugin-bundle.mjs +++ b/scripts/verify-codex-plugin-bundle.mjs @@ -48,10 +48,21 @@ function waitForExit(child, timeoutMs = 20_000) { }); } -function startMcp(launcher, options) { - const child = spawn(process.execPath, [launcher], { +function startMcp(pluginRoot, options) { + const mcp = JSON.parse(readFileSync(path.join(pluginRoot, '.mcp.json'), 'utf8')); + const configured = mcp.mcpServers.specbridge; + const args = configured.args; + const env = { + ...process.env, + SPECBRIDGE_PLUGIN_CACHE_ROOT: options.cacheRoot ?? pluginCache, + ...(options.env ?? {}), + }; + if (!Object.hasOwn(options.env ?? {}, 'SPECBRIDGE_PROJECT_ROOT')) { + delete env.SPECBRIDGE_PROJECT_ROOT; + } + const child = spawn(process.execPath, args, { cwd: options.cwd, - env: { ...process.env, ...(options.env ?? {}) }, + env, stdio: ['pipe', 'pipe', 'pipe'], shell: false, windowsHide: true, @@ -123,9 +134,13 @@ async function closeSession(session) { } const isolatedBase = mkdtempSync(path.join(os.tmpdir(), 'specbridge codex plugin ')); -const pluginCopy = path.join(isolatedBase, 'installed plugin'); +const pluginCache = path.join(isolatedBase, 'plugin cache'); +const stalePlugin = path.join(pluginCache, 'specbridge-local', 'specbridge', '1.0.0-stale'); +const pluginCopy = path.join(pluginCache, 'specbridge-local', 'specbridge', '1.1.0'); const projectRoot = path.join(isolatedBase, 'project with spaces'); const nestedCwd = path.join(projectRoot, 'src', 'nested', 'working-dir'); +cpSync(pluginSource, stalePlugin, { recursive: true }); +rmSync(path.join(stalePlugin, 'dist', 'mcp-server.cjs')); cpSync(pluginSource, pluginCopy, { recursive: true }); mkdirSync(path.join(projectRoot, '.kiro', 'steering'), { recursive: true }); mkdirSync(path.join(projectRoot, '.kiro', 'specs', 'sample-spec'), { recursive: true }); @@ -162,13 +177,18 @@ try { ), ); - const session = startMcp(launcher, { cwd: nestedCwd }); + const session = startMcp(pluginCopy, { cwd: nestedCwd }); const initialized = await initialize(session); check( 'launcher starts the shared MCP bundle from a nested path containing spaces', initialized.result?.serverInfo?.name === 'specbridge', JSON.stringify(initialized.result?.serverInfo), ); + check( + 'self-locating loader selects the newest matching cached plugin installation', + !session.stderr().includes('bundle_missing'), + session.stderr().slice(0, 300), + ); session.send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); const listed = await session.waitFor(2); @@ -249,7 +269,7 @@ try { !normalizedOutput.includes(repoRoot.replaceAll('\\', '/').toLowerCase()), ); - const overrideSession = startMcp(launcher, { + const overrideSession = startMcp(pluginCopy, { cwd: pluginCopy, env: { SPECBRIDGE_PROJECT_ROOT: projectRoot, PWD: pluginCopy }, }); @@ -272,7 +292,7 @@ try { const gitNestedCwd = path.join(gitProjectRoot, 'src', 'nested'); mkdirSync(path.join(gitProjectRoot, '.git'), { recursive: true }); mkdirSync(gitNestedCwd, { recursive: true }); - const gitSession = startMcp(launcher, { cwd: gitNestedCwd }); + const gitSession = startMcp(pluginCopy, { cwd: gitNestedCwd }); await initialize(gitSession); gitSession.send({ jsonrpc: '2.0', @@ -289,10 +309,17 @@ try { ); await closeSession(gitSession); - const brokenPlugin = path.join(isolatedBase, 'broken plugin'); + const brokenCache = path.join(isolatedBase, 'broken cache'); + const brokenPlugin = path.join(brokenCache, 'specbridge-local', 'specbridge', '1.1.0'); mkdirSync(path.join(brokenPlugin, 'dist'), { recursive: true }); + mkdirSync(path.join(brokenPlugin, '.codex-plugin'), { recursive: true }); + cpSync(path.join(pluginCopy, '.mcp.json'), path.join(brokenPlugin, '.mcp.json')); + cpSync( + path.join(pluginCopy, '.codex-plugin', 'plugin.json'), + path.join(brokenPlugin, '.codex-plugin', 'plugin.json'), + ); cpSync(launcher, path.join(brokenPlugin, 'dist', 'mcp-launcher.cjs')); - const broken = startMcp(path.join(brokenPlugin, 'dist', 'mcp-launcher.cjs'), { cwd: projectRoot }); + const broken = startMcp(brokenPlugin, { cwd: projectRoot, cacheRoot: brokenCache }); const brokenExit = await waitForExit(broken.child); check( 'missing MCP bundle fails usefully on stderr and keeps stdout clean', diff --git a/tests/plugin/codex-plugin.test.ts b/tests/plugin/codex-plugin.test.ts index 8688307..dc8e737 100644 --- a/tests/plugin/codex-plugin.test.ts +++ b/tests/plugin/codex-plugin.test.ts @@ -78,7 +78,16 @@ describe('Codex frontend plugin structure', () => { expect(server).toBeDefined(); if (server === undefined) throw new Error('mcpServers.specbridge is missing'); expect(server.command).toBe('node'); - expect(server.args).toEqual(['${PLUGIN_ROOT}/dist/mcp-launcher.cjs']); + expect(server.args).toHaveLength(2); + expect(server.args[0]).toBe('-e'); + expect(server.args[1]).toContain('SPECBRIDGE_PLUGIN_CACHE_ROOT'); + expect(server.args[1]).toContain('process._eval'); + expect(server.args[1]).toContain("manifest.name==='specbridge'"); + expect(server.args[1]).toContain('installed.birthtimeMs'); + expect(server.args[1]).toContain('b.installedAt-a.installedAt'); + expect(server.args[1]).toContain("readFileSync(file,'utf8')"); + expect(server.args[1]).toContain('loaded._compile('); + expect(server.args.join(' ')).not.toContain('${PLUGIN_ROOT}'); expect(server.cwd).toBeUndefined(); expect(server.env).toBeUndefined(); @@ -86,9 +95,11 @@ describe('Codex frontend plugin structure', () => { expect(launcher).toContain('SPECBRIDGE_PROJECT_ROOT'); expect(launcher).toContain('process.cwd()'); expect(launcher).toContain('process.env.PWD'); - expect(launcher).toContain('spawn('); - expect(launcher).toContain('shell: false'); - expect(launcher).toContain("stdio: 'inherit'"); + expect(launcher).toContain('loadCommonJs(serverBundle)'); + expect(launcher).toContain("readFileSync(entryPath, 'utf8')"); + expect(launcher).toContain('loaded._compile('); + expect(launcher).toContain('process.chdir(projectRoot)'); + expect(launcher).not.toContain('spawn('); expect(launcher).not.toContain('cmd.exe'); expect(launcher).not.toContain('CLAUDE_PROJECT_DIR'); });