From df5dcb6e5095956db1cc845c34fb55815861b8df Mon Sep 17 00:00:00 2001 From: autogame-17 <17@evomap.ai> Date: Wed, 26 Aug 2026 01:53:03 +0800 Subject: [PATCH] adapters: write a unique evox-product MCP config and stdio shim Desktop Connect already shells out to setup-hooks, but the installer only wrote hooks. External Claude/Codex need a managed evox-product stdio proxy that fails closed until Desktop publishes a loopback grant. --- src/adapters/claudeCode.js | 15 +- src/adapters/codex.js | 19 +- src/adapters/hookAdapter.js | 4 +- src/adapters/productBridgeMcp.js | 367 ++++++++++++++++++++++ src/adapters/scripts/evox-product-shim.js | 202 ++++++++++++ test/productBridgeMcp.test.js | 254 +++++++++++++++ 6 files changed, 858 insertions(+), 3 deletions(-) create mode 100644 src/adapters/productBridgeMcp.js create mode 100644 src/adapters/scripts/evox-product-shim.js create mode 100644 test/productBridgeMcp.test.js diff --git a/src/adapters/claudeCode.js b/src/adapters/claudeCode.js index 9721d10c..4eec49a7 100644 --- a/src/adapters/claudeCode.js +++ b/src/adapters/claudeCode.js @@ -1,6 +1,7 @@ const fs = require('fs'); const path = require('path'); const { mergeJsonFile, copyHookScripts, verifyHookScriptCopies, appendSectionToFile, removeHookScripts, removeMarkedSection, assertSafeConfigDir, isEvolverHookCommand, buildSafeNodeHookCommand } = require('./hookAdapter'); +const productBridgeMcp = require('./productBridgeMcp'); const HOOK_SCRIPTS_DIR_NAME = 'hooks'; const EVOLVER_MARKER = ''; @@ -109,12 +110,19 @@ function install({ configRoot, evolverRoot, force }) { console.log('[claude-code] Injected evolution section into ' + claudeMdPath); } + const mcp = productBridgeMcp.installClaudeJson({ configRoot, evolverRoot, force }); + if (mcp.changed) { + console.log('[claude-code] Wrote product-bridge MCP ' + mcp.path); + } else if (mcp.skipped) { + console.log('[claude-code] Left a user-owned evox-product MCP entry in place'); + } + console.log('[claude-code] Installation complete.'); return { ok: true, platform: 'claude-code', - files: [settingsPath, claudeMdPath, ...copied], + files: [settingsPath, claudeMdPath, mcp.path, ...copied], }; } @@ -198,6 +206,7 @@ function verify({ configRoot }) { ? 'CLAUDE.md contains the managed evolution section' : 'CLAUDE.md is missing the managed evolution section', }); + checks.push(...productBridgeMcp.verifyClaudeJson({ configRoot }).checks); return { ok: checks.every(check => check.ok), @@ -282,6 +291,10 @@ function uninstall({ configRoot }) { changed = true; } + if (productBridgeMcp.uninstallClaudeJson({ configRoot })) { + changed = true; + } + console.log(changed ? '[claude-code] Uninstalled evolver hooks.' : '[claude-code] No evolver hooks found to uninstall.'); diff --git a/src/adapters/codex.js b/src/adapters/codex.js index 039ac94f..2ea9b927 100644 --- a/src/adapters/codex.js +++ b/src/adapters/codex.js @@ -1,6 +1,7 @@ const fs = require('fs'); const path = require('path'); const { mergeJsonFile, copyHookScripts, verifyHookScriptCopies, appendSectionToFile, removeHookScripts, removeMarkedSection, assertSafeConfigDir, isEvolverHookCommand } = require('./hookAdapter'); +const productBridgeMcp = require('./productBridgeMcp'); const HOOK_SCRIPTS_DIR_NAME = 'hooks'; const EVOLVER_MARKER = ''; @@ -209,8 +210,12 @@ function install({ configRoot, evolverRoot, force }) { try { const existing = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); if (existing._evolver_managed) { + const mcp = productBridgeMcp.installCodexToml({ configRoot, evolverRoot, force }); + if (mcp.changed) { + console.log('[codex] Wrote product-bridge MCP ' + mcp.path); + } console.log('[codex] Evolver hooks already installed. Use --force to overwrite.'); - return { ok: true, skipped: true }; + return { ok: true, skipped: true, files: mcp.changed ? [mcp.path] : [] }; } } catch { /* proceed */ } } @@ -234,6 +239,13 @@ function install({ configRoot, evolverRoot, force }) { console.log('[codex] Injected evolution section into ' + agentsMdPath); } + const mcp = productBridgeMcp.installCodexToml({ configRoot, evolverRoot, force }); + if (mcp.changed) { + console.log('[codex] Wrote product-bridge MCP ' + mcp.path); + } else if (mcp.skipped) { + console.log('[codex] Left a user-owned evox-product MCP table in place'); + } + console.log('[codex] Installation complete.'); return { @@ -319,6 +331,7 @@ function verify({ configRoot }) { ? 'AGENTS.md contains the managed evolution section' : 'AGENTS.md is missing the managed evolution section', }); + checks.push(...productBridgeMcp.verifyCodexToml({ configRoot }).checks); return { ok: checks.every(check => check.ok), @@ -396,6 +409,10 @@ function uninstall({ configRoot }) { changed = true; } + if (productBridgeMcp.uninstallCodexToml({ configRoot })) { + changed = true; + } + if (removeMarkedSection(agentsMdPath, EVOLVER_MARKER)) { changed = true; } diff --git a/src/adapters/hookAdapter.js b/src/adapters/hookAdapter.js index 131991cf..cda0809d 100644 --- a/src/adapters/hookAdapter.js +++ b/src/adapters/hookAdapter.js @@ -360,7 +360,9 @@ function removeEvolverHooks(filePath, { markerKey = '_evolver_managed' } = {}) { if (Object.keys(data.hooks).length === 0) delete data.hooks; } if (data.mcpServers) { - // Claude Code / Codex: hooks in mcpServers sub-key -- not relevant, skip + // Product-bridge MCP is owned by productBridgeMcp.js, not this + // hooks cleaner. Leaving mcpServers untouched avoids deleting a + // user server just because the file also carries _evolver_managed. } delete data[markerKey]; const tmp = filePath + '.tmp'; diff --git a/src/adapters/productBridgeMcp.js b/src/adapters/productBridgeMcp.js new file mode 100644 index 00000000..b8ec9120 --- /dev/null +++ b/src/adapters/productBridgeMcp.js @@ -0,0 +1,367 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { assertNotSymlink } = require('./hookAdapter'); + +const SERVER_NAME = 'evox-product'; +const SHIM_NAME = 'evox-product-shim.js'; +const MANAGED_KEY = '_evox_product_managed'; +const TOML_MARKER = '# evox-product-managed'; +const TOML_SECTION = `mcp_servers.${SERVER_NAME}`; +const GRANT_SCHEMA = 'evox.product_bridge.grant.v1'; + +function shimPath(evolverRoot) { + return path.join(evolverRoot, 'src', 'adapters', 'scripts', SHIM_NAME); +} + +function buildServerEntry(evolverRoot) { + return { + command: process.execPath, + args: [shimPath(evolverRoot)], + [MANAGED_KEY]: true, + }; +} + +function isOwnedServer(entry) { + if (!entry || typeof entry !== 'object') return false; + if (entry[MANAGED_KEY] === true) return true; + const args = Array.isArray(entry.args) ? entry.args : []; + return args.some(arg => String(arg).endsWith(SHIM_NAME)); +} + +function writeJsonAtomic(filePath, data) { + const tmp = filePath + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8'); + fs.renameSync(tmp, filePath); +} + +function readJsonFile(filePath) { + const raw = fs.readFileSync(filePath, 'utf8').trim(); + if (!raw) return {}; + return JSON.parse(raw); +} + +function installClaudeJson({ configRoot, evolverRoot, force }) { + const filePath = path.join(configRoot, '.mcp.json'); + assertNotSymlink(filePath, '.mcp.json'); + const entry = buildServerEntry(evolverRoot); + if (!fs.existsSync(entry.args[0])) { + return { changed: false, path: filePath, error: `missing shim ${entry.args[0]}` }; + } + + let data = { mcpServers: {} }; + try { + if (fs.existsSync(filePath)) data = readJsonFile(filePath); + } catch { + data = { mcpServers: {} }; + } + if (!data || typeof data !== 'object') data = {}; + if (!data.mcpServers || typeof data.mcpServers !== 'object' || Array.isArray(data.mcpServers)) { + data.mcpServers = {}; + } + + const existing = data.mcpServers[SERVER_NAME]; + if (existing && !isOwnedServer(existing) && !force) { + return { changed: false, path: filePath, skipped: true }; + } + + data.mcpServers[SERVER_NAME] = entry; + writeJsonAtomic(filePath, data); + return { changed: true, path: filePath }; +} + +function uninstallClaudeJson({ configRoot }) { + const filePath = path.join(configRoot, '.mcp.json'); + assertNotSymlink(filePath, '.mcp.json'); + if (!fs.existsSync(filePath)) return false; + let data; + try { + data = readJsonFile(filePath); + } catch { + return false; + } + if (!data || typeof data !== 'object' || !data.mcpServers || typeof data.mcpServers !== 'object') { + return false; + } + if (!isOwnedServer(data.mcpServers[SERVER_NAME])) return false; + delete data.mcpServers[SERVER_NAME]; + if (Object.keys(data.mcpServers).length === 0) delete data.mcpServers; + if (Object.keys(data).length === 0) { + fs.unlinkSync(filePath); + return true; + } + writeJsonAtomic(filePath, data); + return true; +} + +function verifyClaudeJson({ configRoot }) { + const filePath = path.join(configRoot, '.mcp.json'); + const checks = []; + let data = null; + let error = null; + try { + data = readJsonFile(filePath); + } catch (err) { + error = err && err.message || String(err); + } + checks.push({ + id: 'product_bridge_mcp_json', + ok: data !== null, + detail: data ? filePath : `unreadable: ${error}`, + }); + const entry = data && data.mcpServers && data.mcpServers[SERVER_NAME]; + const owned = isOwnedServer(entry); + checks.push({ + id: 'product_bridge_managed', + ok: owned, + detail: owned + ? `${SERVER_NAME} is evolver-managed` + : `.mcp.json is missing a managed ${SERVER_NAME} server`, + }); + const commandPath = owned && Array.isArray(entry.args) ? entry.args[0] : ''; + let shimOk = false; + try { + shimOk = Boolean(commandPath) && fs.lstatSync(commandPath).isFile(); + } catch { /* reported below */ } + checks.push({ + id: 'product_bridge_shim', + ok: shimOk, + detail: shimOk + ? `shim present at ${commandPath}` + : `shim missing: ${commandPath || 'no args'}`, + }); + return { checks, path: filePath }; +} + +function tomlBasicString(value) { + return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +function isEscapedAt(line, index) { + let slashes = 0; + for (let cursor = index - 1; cursor >= 0 && line[cursor] === '\\'; cursor -= 1) { + slashes += 1; + } + return slashes % 2 === 1; +} + +function structuralTomlLines(lines) { + let multiline = null; + return lines.map((rawLine) => { + let structural = ''; + let singleQuoted = false; + let doubleQuoted = false; + for (let index = 0; index < rawLine.length; index += 1) { + if (multiline) { + if ( + rawLine.startsWith(multiline, index) && + (multiline === "'''" || !isEscapedAt(rawLine, index)) + ) { + multiline = null; + index += 2; + } + continue; + } + if (!singleQuoted && !doubleQuoted && rawLine[index] === '#') break; + if (!singleQuoted && !doubleQuoted && rawLine.startsWith('"""', index)) { + multiline = '"""'; + index += 2; + continue; + } + if (!singleQuoted && !doubleQuoted && rawLine.startsWith("'''", index)) { + multiline = "'''"; + index += 2; + continue; + } + if (!doubleQuoted && rawLine[index] === "'") singleQuoted = !singleQuoted; + if (!singleQuoted && rawLine[index] === '"' && !isEscapedAt(rawLine, index)) { + doubleQuoted = !doubleQuoted; + } + structural += rawLine[index]; + } + return structural.trim(); + }); +} + +function tomlSectionName(line) { + const header = String(line).match(/^\[([^\]]+)\]$/); + return header ? header[1].trim() : null; +} + +function findTomlSection(lines, name) { + const structural = structuralTomlLines(lines); + let start = -1; + for (let index = 0; index < structural.length; index += 1) { + const section = tomlSectionName(structural[index]); + if (section === name) { + start = index; + continue; + } + if (start >= 0 && section !== null) { + return { start, end: index }; + } + } + return start >= 0 ? { start, end: lines.length } : null; +} + +function sectionLooksOwned(lines, section) { + if (!section) return false; + const before = section.start > 0 ? String(lines[section.start - 1]).trim() : ''; + if (before === TOML_MARKER) return true; + return lines + .slice(section.start, section.end) + .some(line => String(line).includes(SHIM_NAME)); +} + +function renderTomlSection(evolverRoot) { + return [ + TOML_MARKER, + `[${TOML_SECTION}]`, + `command = ${tomlBasicString(process.execPath)}`, + `args = [${tomlBasicString(shimPath(evolverRoot))}]`, + '', + ].join('\n'); +} + +function installCodexToml({ configRoot, evolverRoot, force }) { + const filePath = path.join(configRoot, '.codex', 'config.toml'); + assertNotSymlink(filePath, 'config.toml'); + const commandPath = shimPath(evolverRoot); + if (!fs.existsSync(commandPath)) { + return { changed: false, path: filePath, error: `missing shim ${commandPath}` }; + } + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + + let source = ''; + try { source = fs.readFileSync(filePath, 'utf8'); } catch { /* new file */ } + const newline = source.includes('\r\n') ? '\r\n' : '\n'; + const lines = source === '' ? [] : source.split(/\r?\n/); + const section = findTomlSection(lines, TOML_SECTION); + if (section && !sectionLooksOwned(lines, section) && !force) { + return { changed: false, path: filePath, skipped: true }; + } + + const blockLines = renderTomlSection(evolverRoot) + .replace(/\r\n/g, '\n') + .replace(/\n$/g, '') + .split('\n'); + if (section) { + let start = section.start; + if (start > 0 && String(lines[start - 1]).trim() === TOML_MARKER) start -= 1; + lines.splice(start, section.end - start, ...blockLines); + } else { + // Prepend so a later user append still lands in the existing [features] + // table, and so our ownership comment is never inside that table. + if (lines.length && String(lines[0]).trim() !== '') blockLines.push(''); + lines.unshift(...blockLines); + } + const out = lines.join(newline); + fs.writeFileSync(filePath, out.endsWith(newline) ? out : out + newline, 'utf8'); + return { changed: true, path: filePath }; +} + +function uninstallCodexToml({ configRoot }) { + const filePath = path.join(configRoot, '.codex', 'config.toml'); + assertNotSymlink(filePath, 'config.toml'); + let source; + try { source = fs.readFileSync(filePath, 'utf8'); } catch { return false; } + const newline = source.includes('\r\n') ? '\r\n' : '\n'; + const lines = source.split(/\r?\n/); + const section = findTomlSection(lines, TOML_SECTION); + if (!section || !sectionLooksOwned(lines, section)) return false; + const remove = []; + if (section.start > 0 && String(lines[section.start - 1]).trim() === TOML_MARKER) { + remove.push(section.start - 1); + } + remove.push(section.start); + for (let index = section.start + 1; index < section.end; index += 1) { + const trimmed = String(lines[index]).trim(); + if (!trimmed) { + remove.push(index); + continue; + } + if (/^(command|args)\s*=/.test(trimmed)) remove.push(index); + } + const leftover = []; + for (let index = section.start + 1; index < section.end; index += 1) { + if (!remove.includes(index) && String(lines[index]).trim()) leftover.push(index); + } + if (leftover.length > 0) { + // A foreign key lives in this table. Keep the header; drop only our keys. + const drop = new Set(remove.filter(index => index !== section.start)); + for (const index of [...drop].sort((a, b) => b - a)) lines.splice(index, 1); + } else { + for (const index of remove.sort((a, b) => b - a)) lines.splice(index, 1); + } + while (lines.length && lines[lines.length - 1] === '') lines.pop(); + const next = lines.join(newline); + fs.writeFileSync(filePath, next.trim() ? (next.endsWith(newline) ? next : next + newline) : '', 'utf8'); + return true; +} + +function verifyCodexToml({ configRoot }) { + const filePath = path.join(configRoot, '.codex', 'config.toml'); + const checks = []; + let source = ''; + let error = null; + try { + source = fs.readFileSync(filePath, 'utf8'); + } catch (err) { + error = err && err.message || String(err); + } + const readable = error === null; + checks.push({ + id: 'product_bridge_codex_toml', + ok: readable, + detail: readable ? filePath : `unreadable: ${error}`, + }); + const lines = readable ? source.split(/\r?\n/) : []; + const section = findTomlSection(lines, TOML_SECTION); + const owned = sectionLooksOwned(lines, section); + checks.push({ + id: 'product_bridge_managed', + ok: owned, + detail: owned + ? `${TOML_SECTION} is evolver-managed` + : `config.toml is missing a managed ${TOML_SECTION} table`, + }); + let commandPath = ''; + if (section) { + for (const line of lines.slice(section.start, section.end)) { + const match = String(line).match(/^\s*args\s*=\s*\[\s*"((?:\\.|[^"\\])*)"/); + if (match) { + commandPath = match[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + break; + } + } + } + let shimOk = false; + try { + shimOk = Boolean(commandPath) && fs.lstatSync(commandPath).isFile(); + } catch { /* reported below */ } + checks.push({ + id: 'product_bridge_shim', + ok: shimOk, + detail: shimOk + ? `shim present at ${commandPath}` + : `shim missing: ${commandPath || 'no args'}`, + }); + return { checks, path: filePath }; +} + +module.exports = { + SERVER_NAME, + SHIM_NAME, + MANAGED_KEY, + TOML_MARKER, + TOML_SECTION, + GRANT_SCHEMA, + shimPath, + installClaudeJson, + uninstallClaudeJson, + verifyClaudeJson, + installCodexToml, + uninstallCodexToml, + verifyCodexToml, +}; diff --git a/src/adapters/scripts/evox-product-shim.js b/src/adapters/scripts/evox-product-shim.js new file mode 100644 index 00000000..af8f6641 --- /dev/null +++ b/src/adapters/scripts/evox-product-shim.js @@ -0,0 +1,202 @@ +#!/usr/bin/env node +'use strict'; + +// Stdio MCP proxy for EvoX product tools. The host (Claude Code / Codex) +// launches this on demand; we never listen on a port. Desktop publishes +// the loopback URL + grant at ~/.evox/product-bridge.json (or +// EVOX_PRODUCT_BRIDGE_GRANT_FILE). Missing grant is a hard RPC error, not +// a fake empty tool list. + +const crypto = require('crypto'); +const fs = require('fs'); +const http = require('http'); +const os = require('os'); +const path = require('path'); + +const GRANT_SCHEMA = 'evox.product_bridge.grant.v1'; +const GRANT_HEADER = 'X-Evox-Product-Bridge-Grant'; +const NONCE_HEADER = 'X-Evox-Product-Bridge-Nonce'; +const MAX_GRANT_BYTES = 64 * 1024; +const MAX_RPC_BYTES = 2 * 1024 * 1024; +const REQUEST_TIMEOUT_MS = 30_000; + +function grantFilePath(env = process.env) { + const override = String(env.EVOX_PRODUCT_BRIDGE_GRANT_FILE || '').trim(); + if (override) return override; + return path.join(os.homedir(), '.evox', 'product-bridge.json'); +} + +function isLoopbackHttp(raw) { + try { + const url = new URL(String(raw || '')); + const host = url.hostname.toLowerCase(); + return url.protocol === 'http:' && ( + host === '127.0.0.1' || host === 'localhost' || host === '::1' + ); + } catch { + return false; + } +} + +function readGrant(filePath = grantFilePath()) { + let st; + try { + st = fs.lstatSync(filePath); + } catch { + throw new Error( + 'EvoX Desktop is not publishing a product-bridge grant. Start EvoX Desktop and retry.' + ); + } + if (st.isSymbolicLink() || !st.isFile() || st.size > MAX_GRANT_BYTES) { + throw new Error('product-bridge grant file is not a regular file'); + } + const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); + if (!data || data.schema !== GRANT_SCHEMA) { + throw new Error('product-bridge grant schema is not ' + GRANT_SCHEMA); + } + if (!isLoopbackHttp(data.url) || !String(data.grant || '').trim()) { + throw new Error('product-bridge grant is missing a loopback URL or token'); + } + return { url: String(data.url).trim(), grant: String(data.grant).trim() }; +} + +function postJson(url, body, headers) { + return new Promise((resolve, reject) => { + const target = new URL(url); + const payload = Buffer.from(JSON.stringify(body), 'utf8'); + if (payload.length > MAX_RPC_BYTES) { + reject(new Error('product-bridge request is too large')); + return; + } + const req = http.request({ + protocol: target.protocol, + hostname: target.hostname, + port: target.port, + path: target.pathname + target.search, + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': payload.length, + ...headers, + }, + }, (res) => { + const chunks = []; + let size = 0; + res.on('data', (chunk) => { + size += chunk.length; + if (size > MAX_RPC_BYTES) { + req.destroy(); + reject(new Error('product-bridge response is too large')); + return; + } + chunks.push(chunk); + }); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + try { + resolve(JSON.parse(raw)); + } catch { + reject(new Error('product-bridge returned invalid JSON')); + } + }); + }); + req.setTimeout(REQUEST_TIMEOUT_MS, () => { + req.destroy(); + reject(new Error('product-bridge request timed out')); + }); + req.on('error', reject); + req.end(payload); + }); +} + +function writeFrame(message) { + const body = Buffer.from(JSON.stringify(message), 'utf8'); + process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`); + process.stdout.write(body); +} + +function rpcError(id, message) { + return { jsonrpc: '2.0', id: id ?? null, error: { code: -32000, message } }; +} + +async function dispatch(req) { + if (!req || req.jsonrpc !== '2.0' || !req.method) { + return rpcError(req && req.id, 'invalid JSON-RPC request'); + } + if (req.id === undefined) return null; + let grant; + try { + grant = readGrant(); + } catch (err) { + return rpcError(req.id, err.message || String(err)); + } + const headers = { [GRANT_HEADER]: grant.grant }; + if (req.method === 'tools/call') { + headers[NONCE_HEADER] = crypto.randomBytes(16).toString('hex'); + } + try { + const response = await postJson(grant.url, req, headers); + if (!response || typeof response !== 'object') { + return rpcError(req.id, 'product-bridge returned an empty response'); + } + response.id = req.id; + response.jsonrpc = '2.0'; + return response; + } catch (err) { + return rpcError(req.id, err.message || String(err)); + } +} + +function consumeFrames(buffer, onMessage) { + let offset = 0; + while (offset < buffer.length) { + const headerEnd = buffer.indexOf('\r\n\r\n', offset); + if (headerEnd === -1) break; + const header = buffer.slice(offset, headerEnd).toString('utf8'); + const lengthMatch = /content-length:\s*(\d+)/i.exec(header); + if (!lengthMatch) { + throw new Error('stdio frame is missing Content-Length'); + } + const length = Number(lengthMatch[1]); + const bodyStart = headerEnd + 4; + if (buffer.length < bodyStart + length) break; + const body = buffer.slice(bodyStart, bodyStart + length).toString('utf8'); + onMessage(JSON.parse(body)); + offset = bodyStart + length; + } + return buffer.slice(offset); +} + +async function main() { + let pending = Buffer.alloc(0); + let queue = Promise.resolve(); + process.stdin.on('data', (chunk) => { + pending = Buffer.concat([pending, chunk]); + try { + pending = consumeFrames(pending, (message) => { + queue = queue.then(async () => { + const response = await dispatch(message); + if (response) writeFrame(response); + }).catch((err) => { + writeFrame(rpcError(null, err.message || String(err))); + }); + }); + } catch (err) { + writeFrame(rpcError(null, err.message || String(err))); + pending = Buffer.alloc(0); + } + }); +} + +if (require.main === module) { + main(); +} + +module.exports = { + GRANT_SCHEMA, + grantFilePath, + isLoopbackHttp, + readGrant, + consumeFrames, + dispatch, +}; diff --git a/test/productBridgeMcp.test.js b/test/productBridgeMcp.test.js new file mode 100644 index 00000000..880f630e --- /dev/null +++ b/test/productBridgeMcp.test.js @@ -0,0 +1,254 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const http = require('http'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); + +const productBridgeMcp = require('../src/adapters/productBridgeMcp'); +const shim = require('../src/adapters/scripts/evox-product-shim.js'); +const claudeAdapter = require('../src/adapters/claudeCode'); +const codexAdapter = require('../src/adapters/codex'); + +function makeTmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-product-bridge-')); +} + +function cleanup(dir) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } +} + +const evolverRoot = path.resolve(__dirname, '..'); + +describe('productBridgeMcp writer', () => { + it('installs a managed evox-product entry in .mcp.json without touching others', () => { + const tmp = makeTmpDir(); + try { + fs.writeFileSync(path.join(tmp, '.mcp.json'), JSON.stringify({ + mcpServers: { playwright: { command: 'npx' } }, + })); + const result = productBridgeMcp.installClaudeJson({ + configRoot: tmp, evolverRoot, force: false, + }); + assert.equal(result.changed, true); + const data = JSON.parse(fs.readFileSync(path.join(tmp, '.mcp.json'), 'utf8')); + assert.deepEqual(data.mcpServers.playwright, { command: 'npx' }); + assert.equal(data.mcpServers['evox-product']._evox_product_managed, true); + assert.equal(data.mcpServers['evox-product'].command, process.execPath); + assert.equal( + data.mcpServers['evox-product'].args[0], + productBridgeMcp.shimPath(evolverRoot) + ); + assert.equal(data._evolver_managed, undefined); + } finally { cleanup(tmp); } + }); + + it('leaves a user-owned evox-product Claude entry in place', () => { + const tmp = makeTmpDir(); + try { + fs.writeFileSync(path.join(tmp, '.mcp.json'), JSON.stringify({ + mcpServers: { 'evox-product': { command: 'other' } }, + })); + const result = productBridgeMcp.installClaudeJson({ + configRoot: tmp, evolverRoot, force: false, + }); + assert.equal(result.skipped, true); + const data = JSON.parse(fs.readFileSync(path.join(tmp, '.mcp.json'), 'utf8')); + assert.equal(data.mcpServers['evox-product'].command, 'other'); + } finally { cleanup(tmp); } + }); + + it('uninstall removes only a managed Claude evox-product entry', () => { + const tmp = makeTmpDir(); + try { + productBridgeMcp.installClaudeJson({ configRoot: tmp, evolverRoot, force: true }); + const mcpPath = path.join(tmp, '.mcp.json'); + const data = JSON.parse(fs.readFileSync(mcpPath, 'utf8')); + data.mcpServers.playwright = { command: 'npx' }; + fs.writeFileSync(mcpPath, JSON.stringify(data)); + assert.equal(productBridgeMcp.uninstallClaudeJson({ configRoot: tmp }), true); + const after = JSON.parse(fs.readFileSync(mcpPath, 'utf8')); + assert.equal(after.mcpServers['evox-product'], undefined); + assert.deepEqual(after.mcpServers.playwright, { command: 'npx' }); + } finally { cleanup(tmp); } + }); + + it('installs and uninstalls a managed Codex MCP table without rewriting [features]', () => { + const tmp = makeTmpDir(); + try { + const tomlPath = path.join(tmp, '.codex', 'config.toml'); + fs.mkdirSync(path.dirname(tomlPath), { recursive: true }); + fs.writeFileSync(tomlPath, '[features]\ncodex_hooks = true\nuser_feature = true\n'); + const result = productBridgeMcp.installCodexToml({ + configRoot: tmp, evolverRoot, force: false, + }); + assert.equal(result.changed, true); + const installed = fs.readFileSync(tomlPath, 'utf8'); + assert.ok(installed.includes('[features]')); + assert.ok(installed.includes('user_feature = true')); + assert.ok(installed.includes(productBridgeMcp.TOML_MARKER)); + assert.ok(installed.includes(`[${productBridgeMcp.TOML_SECTION}]`)); + assert.ok(installed.includes(productBridgeMcp.shimPath(evolverRoot))); + + assert.equal(productBridgeMcp.uninstallCodexToml({ configRoot: tmp }), true); + const after = fs.readFileSync(tomlPath, 'utf8'); + assert.ok(!after.includes('evox-product')); + assert.ok(after.includes('user_feature = true')); + assert.ok(after.includes('codex_hooks = true')); + } finally { cleanup(tmp); } + }); + + it('leaves a user-owned Codex evox-product table in place', () => { + const tmp = makeTmpDir(); + try { + const tomlPath = path.join(tmp, '.codex', 'config.toml'); + fs.mkdirSync(path.dirname(tomlPath), { recursive: true }); + fs.writeFileSync( + tomlPath, + '[mcp_servers.evox-product]\ncommand = "other"\nargs = ["mine"]\n' + ); + const result = productBridgeMcp.installCodexToml({ + configRoot: tmp, evolverRoot, force: false, + }); + assert.equal(result.skipped, true); + const after = fs.readFileSync(tomlPath, 'utf8'); + assert.ok(after.includes('command = "other"')); + assert.ok(!after.includes(productBridgeMcp.TOML_MARKER)); + } finally { cleanup(tmp); } + }); + + it('claude and codex adapters write and verify the product-bridge MCP', () => { + const tmp = makeTmpDir(); + try { + claudeAdapter.install({ configRoot: tmp, evolverRoot, force: true }); + const claude = claudeAdapter.verify({ configRoot: tmp }); + assert.equal(claude.ok, true, JSON.stringify(claude.checks, null, 2)); + assert.ok(claude.checks.some(check => check.id === 'product_bridge_managed' && check.ok)); + + claudeAdapter.uninstall({ configRoot: tmp }); + assert.ok(!fs.existsSync(path.join(tmp, '.mcp.json'))); + + const tmp2 = makeTmpDir(); + try { + codexAdapter.install({ configRoot: tmp2, evolverRoot, force: true }); + const codex = codexAdapter.verify({ configRoot: tmp2 }); + assert.equal(codex.ok, true, JSON.stringify(codex.checks, null, 2)); + assert.ok(codex.checks.some(check => check.id === 'product_bridge_managed' && check.ok)); + const toml = fs.readFileSync(path.join(tmp2, '.codex', 'config.toml'), 'utf8'); + assert.ok(toml.includes('codex_hooks = true')); + assert.ok(toml.includes('[mcp_servers.evox-product]')); + } finally { cleanup(tmp2); } + } finally { cleanup(tmp); } + }); +}); + +describe('evox-product-shim', () => { + it('accepts only loopback http grant URLs', () => { + assert.equal(shim.isLoopbackHttp('http://127.0.0.1:9/mcp/product-bridge/x'), true); + assert.equal(shim.isLoopbackHttp('http://localhost/mcp'), true); + assert.equal(shim.isLoopbackHttp('https://127.0.0.1/mcp'), false); + assert.equal(shim.isLoopbackHttp('http://example.com/mcp'), false); + }); + + it('fails closed when the grant file is missing or not loopback', () => { + const tmp = makeTmpDir(); + try { + const missing = path.join(tmp, 'missing.json'); + assert.throws(() => shim.readGrant(missing), /not publishing a product-bridge grant/); + + const bad = path.join(tmp, 'bad.json'); + fs.writeFileSync(bad, JSON.stringify({ + schema: shim.GRANT_SCHEMA, + url: 'http://example.com/mcp', + grant: 'ab', + })); + assert.throws(() => shim.readGrant(bad), /loopback URL or token/); + } finally { cleanup(tmp); } + }); + + it('proxies initialize and sends a nonce only on tools/call', async () => { + const tmp = makeTmpDir(); + const seen = []; + const server = http.createServer((req, res) => { + const chunks = []; + req.on('data', chunk => chunks.push(chunk)); + req.on('end', () => { + seen.push({ + method: JSON.parse(Buffer.concat(chunks).toString('utf8')).method, + grant: req.headers['x-evox-product-bridge-grant'], + nonce: req.headers['x-evox-product-bridge-nonce'], + }); + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { ok: true } })); + }); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + const grantPath = path.join(tmp, 'product-bridge.json'); + fs.writeFileSync(grantPath, JSON.stringify({ + schema: shim.GRANT_SCHEMA, + url: `http://127.0.0.1:${port}/mcp/product-bridge/token`, + grant: 'abc123', + boot_id: 1, + })); + const previous = process.env.EVOX_PRODUCT_BRIDGE_GRANT_FILE; + process.env.EVOX_PRODUCT_BRIDGE_GRANT_FILE = grantPath; + try { + const init = await shim.dispatch({ jsonrpc: '2.0', id: 1, method: 'initialize' }); + assert.equal(init.result.ok, true); + const call = await shim.dispatch({ + jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'create_session' }, + }); + assert.equal(call.result.ok, true); + assert.equal(seen[0].method, 'initialize'); + assert.equal(seen[0].grant, 'abc123'); + assert.equal(seen[0].nonce, undefined); + assert.equal(seen[1].method, 'tools/call'); + assert.equal(seen[1].grant, 'abc123'); + assert.match(seen[1].nonce, /^[0-9a-f]{32}$/); + } finally { + if (previous === undefined) delete process.env.EVOX_PRODUCT_BRIDGE_GRANT_FILE; + else process.env.EVOX_PRODUCT_BRIDGE_GRANT_FILE = previous; + await new Promise(resolve => server.close(resolve)); + cleanup(tmp); + } + }); + + it('stdio initialize fails closed without a grant file', async () => { + const tmp = makeTmpDir(); + const previous = process.env.EVOX_PRODUCT_BRIDGE_GRANT_FILE; + process.env.EVOX_PRODUCT_BRIDGE_GRANT_FILE = path.join(tmp, 'missing.json'); + const child = spawn(process.execPath, [ + path.join(evolverRoot, 'src', 'adapters', 'scripts', 'evox-product-shim.js'), + ], { stdio: ['pipe', 'pipe', 'pipe'] }); + const payload = Buffer.from(JSON.stringify({ + jsonrpc: '2.0', id: 1, method: 'initialize', + }), 'utf8'); + child.stdin.write(`Content-Length: ${payload.length}\r\n\r\n`); + child.stdin.write(payload); + const chunks = []; + child.stdout.on('data', chunk => chunks.push(chunk)); + const response = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('shim stdio timed out')), 5000); + child.stdout.on('data', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + if (!raw.includes('\r\n\r\n')) return; + const headerEnd = raw.indexOf('\r\n\r\n'); + const length = Number(/content-length:\s*(\d+)/i.exec(raw)[1]); + const body = raw.slice(headerEnd + 4); + if (Buffer.byteLength(body, 'utf8') < length) return; + clearTimeout(timer); + resolve(JSON.parse(body.slice(0, length))); + }); + child.on('error', reject); + }); + child.kill(); + if (previous === undefined) delete process.env.EVOX_PRODUCT_BRIDGE_GRANT_FILE; + else process.env.EVOX_PRODUCT_BRIDGE_GRANT_FILE = previous; + cleanup(tmp); + assert.equal(response.error.message.includes('not publishing a product-bridge grant'), true); + }); +});