Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion integrations/codex-plugin/specbridge/.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions integrations/codex-plugin/specbridge/dist/checksums.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"bytes": 5858878
},
"mcp-launcher.cjs": {
"sha256": "205d2730178bcdb5a06e199a42a6a6fb0977f42d706f3d445aec801a60874b3e",
"bytes": 4567
"sha256": "5ad0dca58e1f0195ba8819144d44c5285bf43a360090e681594df050899c7770",
"bytes": 4178
},
"mcp-server.cjs": {
"sha256": "090a7686c19a94495baa75b230899f15a8e0b6036cfb09f73d4482686b621a3e",
Expand Down
64 changes: 21 additions & 43 deletions integrations/codex-plugin/specbridge/dist/mcp-launcher.cjs
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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',
Expand All @@ -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);
});
}
}
}
64 changes: 21 additions & 43 deletions integrations/codex-plugin/src/mcp-launcher.cjs
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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',
Expand All @@ -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);
});
}
}
}
14 changes: 12 additions & 2 deletions scripts/validate-codex-plugin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
45 changes: 36 additions & 9 deletions scripts/verify-codex-plugin-bundle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 },
});
Expand All @@ -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',
Expand All @@ -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',
Expand Down
19 changes: 15 additions & 4 deletions tests/plugin/codex-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,17 +78,28 @@ 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();

const launcher = readFileSync(path.join(pluginRoot, 'dist', 'mcp-launcher.cjs'), 'utf8');
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');
});
Expand Down
Loading