From 8c2f58d02ed4375adf13fc0063a3ebf96fa585bc Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Sat, 26 Sep 2026 18:01:03 +0300 Subject: [PATCH 01/33] feat: sign relayed remote MCP names and hold their headers daemon-side The gateway capability gains an optional `remoteMcps` claim (server names only, at most 16) and accepts a caller-fixed `jti`. A new in-memory registry keyed by that jti holds the real URL and headers of each relayed server for exactly the capability's lifetime, so a containerized run can be handed a name instead of a credential. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Tiberiu Socaci --- src/gateway/mcp-capability.js | 39 ++++++++++- src/mcp/remote-mcp-registry.js | 111 +++++++++++++++++++++++++++++++ test/mcp-capability.test.js | 31 +++++++++ test/remote-mcp-registry.test.js | 56 ++++++++++++++++ 4 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 src/mcp/remote-mcp-registry.js create mode 100644 test/remote-mcp-registry.test.js diff --git a/src/gateway/mcp-capability.js b/src/gateway/mcp-capability.js index 12c0b6a7..80c3b2c0 100644 --- a/src/gateway/mcp-capability.js +++ b/src/gateway/mcp-capability.js @@ -18,10 +18,34 @@ const sign = (payload, secret) => createHmac("sha256", secret).update(payload).d // older build (or by a path that still relies on the env) verifies exactly as before and the // reader falls back to its environment. Neither can WIDEN anything: an absent/blank toolset is the // full control plane (today's default) and progressReport only adds one ack-only tool. -export function mintGatewayCapability({ secret, channelId, slug, authorId, threadKey, origin, engine, principalTrusted = true, toolset = "", progressReport = false, composioSessions = [], now = Date.now(), ttlMs = DEFAULT_TTL_MS } = {}) { +// +// `remoteMcps` (optional, P1 of the container-secrets plan) names the header-bearing remote MCP +// servers this run may reach through the daemon's `remote-mcp` socket relay. The names are the +// whole claim — the URL and the credential live only in the daemon's in-memory registry +// (src/mcp/remote-mcp-registry.js), keyed by this token's `jti` — so a container holding the token +// can dial exactly those servers and nothing else, and never sees their headers. `jti` may be +// fixed by the caller so it can register under the same key before handing the token out. +export const REMOTE_MCP_NAME_RE = /^[a-z0-9][a-z0-9-]*$/; +export const MAX_REMOTE_MCPS = 16; +const MAX_REMOTE_MCP_NAME = 64; + +export function validRemoteMcpName(name) { + return typeof name === "string" && name.length > 0 && name.length <= MAX_REMOTE_MCP_NAME && REMOTE_MCP_NAME_RE.test(name); +} + +function validRemoteMcps(claims) { + if (claims.remoteMcps === undefined) return true; // older grants authorize no relayed servers + return Array.isArray(claims.remoteMcps) && claims.remoteMcps.length <= MAX_REMOTE_MCPS && + claims.remoteMcps.every(validRemoteMcpName) && new Set(claims.remoteMcps).size === claims.remoteMcps.length; +} + +export function mintGatewayCapability({ secret, channelId, slug, authorId, threadKey, origin, engine, principalTrusted = true, toolset = "", progressReport = false, composioSessions = [], remoteMcps = undefined, jti = undefined, now = Date.now(), ttlMs = DEFAULT_TTL_MS } = {}) { if (!secret || !channelId || !slug || !authorId || !threadKey || !ORIGINS.has(origin)) { throw new Error("Cannot mint gateway capability without a complete run identity"); } + if (jti !== undefined && (typeof jti !== "string" || !/^[A-Za-z0-9_-]{8,128}$/.test(jti))) { + throw new Error("Invalid gateway capability id"); + } const ttl = Math.min(Math.max(1, Number(ttlMs) || DEFAULT_TTL_MS), MAX_TTL_MS); const claims = { v: VERSION, @@ -36,12 +60,14 @@ export function mintGatewayCapability({ secret, channelId, slug, authorId, threa toolset: String(toolset || ""), progressReport: progressReport === true, composioSessions, + ...(remoteMcps !== undefined ? { remoteMcps } : {}), scope: "gateway-tools", iat: now, exp: now + ttl, - jti: randomUUID(), + jti: jti || randomUUID(), }; if (!validComposioGrants(claims)) throw new Error("Invalid Composio session grants"); + if (!validRemoteMcps(claims)) throw new Error("Invalid remote MCP grants"); const payload = encode(JSON.stringify(claims)); return `${payload}.${sign(payload, secret)}`; } @@ -81,10 +107,19 @@ export function verifyGatewayCapability(token, { secret, now = Date.now() } = {} // omits both is still a valid grant, and the reader falls back to its environment. if (claims.toolset !== undefined && typeof claims.toolset !== "string") return { ok: false, reason: "invalid capability toolset" }; if (claims.progressReport !== undefined && typeof claims.progressReport !== "boolean") return { ok: false, reason: "invalid capability progress claim" }; + if (!validRemoteMcps(claims)) return { ok: false, reason: "invalid remote MCP grants" }; if (!Number.isFinite(claims.iat) || !Number.isFinite(claims.exp) || claims.iat > now + 30_000 || claims.exp <= now || claims.exp - claims.iat > MAX_TTL_MS) { return { ok: false, reason: "expired or invalid capability lifetime" }; } return { ok: true, claims }; } +// The claims of a token THIS process just minted, without re-checking the signature — for the +// minting caller that needs the exp/jti it has to register under (src/gateway/mcp.js). Never an +// authorization decision: anything arriving from outside goes through verifyGatewayCapability. +export function mintedCapabilityClaims(token) { + const [payload] = String(token || "").split("."); + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); +} + export const GATEWAY_CAPABILITY_TTL_MS = DEFAULT_TTL_MS; diff --git a/src/mcp/remote-mcp-registry.js b/src/mcp/remote-mcp-registry.js new file mode 100644 index 00000000..1c9f75cb --- /dev/null +++ b/src/mcp/remote-mcp-registry.js @@ -0,0 +1,111 @@ +// The daemon-side half of the `remote-mcp` socket relay (src/mcp/socket-server.js): which +// header-bearing remote MCP servers (Composio user/agent, the toolboxes) one run capability may +// reach, and the REAL url + headers the daemon dials them with. In memory only, in the daemon +// process that mints the capability (src/gateway/mcp.js) and serves the socket — so the credential +// never has to be written anywhere a container can read: the container holds only the signed +// capability, and the capability names only server NAMES (its `remoteMcps` claim). +// +// Keyed by the capability's `jti`. An entry lives exactly as long as the capability it was +// registered for (its `exp`); expired entries are swept on every lookup and on an unref'd timer, so +// nothing here outlives a grant and nothing here keeps the daemon alive. A registration is never +// renewed: a later run (or a refreshed SSH session) mints a fresh jti and registers again. +// +// Bounded per registration (MAX_SERVERS servers, header values ≤ MAX_HEADER_VALUE_BYTES). Nothing +// in this module logs, and nothing it throws quotes a header value or a URL. +import { MAX_REMOTE_MCPS, validRemoteMcpName } from "../gateway/mcp-capability.js"; + +const MAX_SERVERS = MAX_REMOTE_MCPS; +const MAX_HEADER_VALUE_BYTES = 8 * 1024; +const MAX_HEADERS_PER_SERVER = 16; +const MAX_URL_LENGTH = 2048; +const HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$/; +const SWEEP_INTERVAL_MS = 60 * 1000; + +const registrations = new Map(); // jti → { exp, servers: Map } +let sweeper = null; + +function sweep(now = Date.now()) { + for (const [jti, entry] of registrations) { + if (entry.exp <= now) registrations.delete(jti); + } + if (!registrations.size && sweeper) { + clearInterval(sweeper); + sweeper = null; + } +} + +function ensureSweeper() { + if (sweeper) return; + sweeper = setInterval(() => sweep(), SWEEP_INTERVAL_MS); + sweeper.unref?.(); +} + +function validUrl(value) { + if (typeof value !== "string" || !value || value.length > MAX_URL_LENGTH) return false; + try { + const url = new URL(value); + return url.protocol === "https:" && !url.username && !url.password; + } catch { + return false; + } +} + +function copyHeaders(headers) { + if (!headers || typeof headers !== "object" || Array.isArray(headers)) throw new Error("remote MCP headers must be an object"); + const names = Object.keys(headers); + if (names.length > MAX_HEADERS_PER_SERVER) throw new Error("too many remote MCP headers"); + const out = {}; + for (const name of names) { + const value = headers[name]; + if (!HEADER_NAME_RE.test(name)) throw new Error("invalid remote MCP header name"); + if (typeof value !== "string" || Buffer.byteLength(value) > MAX_HEADER_VALUE_BYTES || /[\r\n\0]/.test(value)) { + throw new Error("invalid remote MCP header value"); + } + out[name] = value; + } + return Object.freeze(out); +} + +/** + * Register the relayed servers of one capability. `servers` is `{ [name]: { url, headers } }`. + * Throws (terse, value-free) on anything malformed; replaces an earlier registration of the same jti. + */ +export function registerRemoteMcps({ jti, exp, servers, now = Date.now() } = {}) { + if (typeof jti !== "string" || !jti) throw new Error("remote MCP registration needs a capability id"); + if (!Number.isFinite(exp) || exp <= now) throw new Error("remote MCP registration needs a live expiry"); + if (!servers || typeof servers !== "object" || Array.isArray(servers)) throw new Error("remote MCP servers must be an object"); + const names = Object.keys(servers); + if (names.length > MAX_SERVERS) throw new Error(`at most ${MAX_SERVERS} remote MCP servers per run`); + const map = new Map(); + for (const name of names) { + if (!validRemoteMcpName(name)) throw new Error("invalid remote MCP server name"); + const server = servers[name]; + if (!server || !validUrl(server.url)) throw new Error("remote MCP servers must be https URLs"); + map.set(name, Object.freeze({ url: server.url, headers: copyHeaders(server.headers || {}) })); + } + sweep(now); + registrations.set(jti, { exp, servers: map }); + ensureSweeper(); + return names; +} + +/** `{ url, headers }` for one server of one live registration, or null. Never the stored object. */ +export function lookupRemoteMcp(jti, name, now = Date.now()) { + sweep(now); + const entry = typeof jti === "string" ? registrations.get(jti) : null; + const server = entry?.servers.get(String(name || "")); + return server ? { url: server.url, headers: { ...server.headers } } : null; +} + +export function clearRemoteMcps(jti) { + registrations.delete(jti); + if (!registrations.size) sweep(); +} + +/** Counts only — for tests and diagnostics; never a name, a URL or a header. */ +export function remoteMcpRegistryStats(now = Date.now()) { + sweep(now); + let servers = 0; + for (const entry of registrations.values()) servers += entry.servers.size; + return { registrations: registrations.size, servers, sweeping: Boolean(sweeper) }; +} diff --git a/test/mcp-capability.test.js b/test/mcp-capability.test.js index ff16e5b7..f283496f 100644 --- a/test/mcp-capability.test.js +++ b/test/mcp-capability.test.js @@ -114,3 +114,34 @@ test("a genuinely signed token is still refused when its claims are not a grant" assert.equal(verifyGatewayCapability(`${signedWith(base)}.extra`, { secret }).reason, "malformed capability"); assert.equal(verifyGatewayCapability(signedWith(base), {}).reason, "missing capability or signing secret"); }); + +// P1 (container secrets): the relayed remote MCP servers a run may reach are named in the signed +// grant; the URL and the credential never are (they live in the daemon's in-memory registry). +test("the optional remoteMcps claim round-trips, is optional, and a caller may fix the jti", async () => { + const { mintedCapabilityClaims } = await import("../src/gateway/mcp-capability.js"); + const token = mintGatewayCapability({ ...identity, remoteMcps: ["composio-user", "make-toolbox"], jti: "fixed-jti-0001", now: 1_000, ttlMs: 5_000 }); + const verified = verifyGatewayCapability(token, { secret, now: 2_000 }); + assert.equal(verified.ok, true); + assert.deepEqual(verified.claims.remoteMcps, ["composio-user", "make-toolbox"]); + assert.equal(verified.claims.jti, "fixed-jti-0001"); + assert.deepEqual(mintedCapabilityClaims(token), verified.claims); + + // Absent: an older token (and every host run) carries no claim and still verifies. + const plain = verifyGatewayCapability(mintGatewayCapability(identity), { secret }); + assert.equal(plain.ok, true); + assert.equal(plain.claims.remoteMcps, undefined); + assert.match(plain.claims.jti, /^[0-9a-f-]{36}$/, "the default jti is still a fresh random UUID"); +}); + +test("a wrong-typed remoteMcps claim is refused at mint and at verify", () => { + for (const bad of ["composio-user", [5], ["Composio"], ["-lead"], ["a".repeat(65)], ["dup", "dup"], Array.from({ length: 17 }, (_, i) => `s${i}`), [{}]]) { + assert.throws(() => mintGatewayCapability({ ...identity, remoteMcps: bad }), /Invalid remote MCP grants/, JSON.stringify(bad)); + } + assert.throws(() => mintGatewayCapability({ ...identity, jti: "short" }), /Invalid gateway capability id/); + assert.throws(() => mintGatewayCapability({ ...identity, jti: 12345678 }), /Invalid gateway capability id/); + const base = verifyGatewayCapability(mintGatewayCapability(identity), { secret }).claims; + for (const bad of ["composio-user", [5], ["UPPER"], null, { a: 1 }]) { + assert.equal(verifyGatewayCapability(signedWith({ ...base, remoteMcps: bad }), { secret }).reason, "invalid remote MCP grants", JSON.stringify(bad)); + } + assert.equal(verifyGatewayCapability(signedWith({ ...base, remoteMcps: [] }), { secret }).ok, true, "an empty list is a valid (empty) grant"); +}); diff --git a/test/remote-mcp-registry.test.js b/test/remote-mcp-registry.test.js new file mode 100644 index 00000000..84cb1126 --- /dev/null +++ b/test/remote-mcp-registry.test.js @@ -0,0 +1,56 @@ +// The daemon's in-memory registry behind the `remote-mcp` socket relay (src/mcp/remote-mcp-registry.js): +// the ONLY place a relayed server's real URL and headers live once a run is containerized. +import test from "node:test"; +import assert from "node:assert/strict"; +import { clearRemoteMcps, lookupRemoteMcp, registerRemoteMcps, remoteMcpRegistryStats } from "../src/mcp/remote-mcp-registry.js"; + +const NOW = 1_700_000_000_000; +const servers = { + "composio-user": { url: "https://connect.composio.dev/mcp", headers: { "x-consumer-api-key": "ck_user_secret" } }, + "make-toolbox": { url: "https://eu1.make.com/mcp/server/abc", headers: { Authorization: "Bearer mk_secret" } }, +}; + +test("a registration answers per jti and name, returns copies, and is gone once cleared", () => { + registerRemoteMcps({ jti: "jti-a", exp: NOW + 10_000, servers, now: NOW }); + const hit = lookupRemoteMcp("jti-a", "composio-user", NOW + 1); + assert.deepEqual(hit, servers["composio-user"]); + hit.headers["x-consumer-api-key"] = "tampered"; + assert.equal(lookupRemoteMcp("jti-a", "composio-user", NOW + 1).headers["x-consumer-api-key"], "ck_user_secret", "callers get a copy"); + assert.equal(lookupRemoteMcp("jti-a", "makeitfuture-toolbox", NOW + 1), null, "a name the run was not given"); + assert.equal(lookupRemoteMcp("jti-other", "composio-user", NOW + 1), null, "another capability's jti"); + assert.deepEqual(remoteMcpRegistryStats(NOW + 1), { registrations: 1, servers: 2, sweeping: true }); + clearRemoteMcps("jti-a"); + assert.equal(lookupRemoteMcp("jti-a", "composio-user", NOW + 1), null); + assert.deepEqual(remoteMcpRegistryStats(NOW + 1), { registrations: 0, servers: 0, sweeping: false }, "an empty registry keeps no timer"); +}); + +test("an entry expires with its capability and is swept on lookup", () => { + registerRemoteMcps({ jti: "jti-b", exp: NOW + 5_000, servers, now: NOW }); + assert.ok(lookupRemoteMcp("jti-b", "make-toolbox", NOW + 4_999)); + assert.equal(lookupRemoteMcp("jti-b", "make-toolbox", NOW + 5_000), null); + assert.equal(remoteMcpRegistryStats(NOW + 5_000).registrations, 0); +}); + +test("registrations are bounded and never echo a value in their refusal", () => { + const many = Object.fromEntries(Array.from({ length: 17 }, (_, i) => [`s${i}`, servers["make-toolbox"]])); + assert.throws(() => registerRemoteMcps({ jti: "j", exp: NOW + 1_000, servers: many, now: NOW }), /at most 16/); + const huge = "x".repeat(8 * 1024 + 1); + for (const [label, bad] of [ + ["oversized header", { a: { url: "https://h.example/mcp", headers: { Authorization: huge } } }], + ["non-string header", { a: { url: "https://h.example/mcp", headers: { Authorization: 5 } } }], + ["header injection", { a: { url: "https://h.example/mcp", headers: { Authorization: "a\r\nX: y" } } }], + ["plain http", { a: { url: "http://h.example/mcp", headers: {} } }], + ["credentials in url", { a: { url: "https://u:p@h.example/mcp", headers: {} } }], + ["bad name", { "Bad Name": { url: "https://h.example/mcp", headers: {} } }], + ]) { + let error; + try { registerRemoteMcps({ jti: "j", exp: NOW + 1_000, servers: bad, now: NOW }); } catch (e) { error = e; } + assert.ok(error, label); + assert.ok(!error.message.includes("xxxx") && !error.message.includes("h.example"), `${label}: terse refusal`); + } + assert.throws(() => registerRemoteMcps({ jti: "j", exp: NOW - 1, servers, now: NOW }), /live expiry/); + assert.throws(() => registerRemoteMcps({ jti: "", exp: NOW + 1_000, servers, now: NOW }), /capability id/); + // Exactly the 8 KB bound is accepted. + registerRemoteMcps({ jti: "j-max", exp: NOW + 1_000, servers: { a: { url: "https://h.example/mcp", headers: { Authorization: "x".repeat(8 * 1024) } } }, now: NOW }); + clearRemoteMcps("j-max"); +}); From f79af57d7f75bfb06db482a162add4b412171a15 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Sat, 26 Sep 2026 18:03:40 +0300 Subject: [PATCH 02/33] feat: relay header-bearing remote MCPs over the daemon socket A new `remote-mcp` service on the control socket: the hello names a server, the daemon checks the capability's signed `remoteMcps` claim AND its in-memory registration under the capability's jti, then dials the real URL with the real headers (Streamable HTTP, HTTP+SSE on a 4xx) and relays tools/list and tools/call, re-authorizing each and forwarding cancellation and progress. Refusals are fixed sentences that quote nothing upstream. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Tiberiu Socaci --- src/mcp/remote-mcp-registry.js | 26 +++++- src/mcp/remote-relay.js | 150 +++++++++++++++++++++++++++++++++ src/mcp/socket-server.js | 64 +++++++++++--- test/mcp-remote-relay.test.js | 139 ++++++++++++++++++++++++++++++ test/mcp-socket-server.test.js | 133 +++++++++++++++++++++++++++-- 5 files changed, 496 insertions(+), 16 deletions(-) create mode 100644 src/mcp/remote-relay.js create mode 100644 test/mcp-remote-relay.test.js diff --git a/src/mcp/remote-mcp-registry.js b/src/mcp/remote-mcp-registry.js index 1c9f75cb..289307ab 100644 --- a/src/mcp/remote-mcp-registry.js +++ b/src/mcp/remote-mcp-registry.js @@ -23,8 +23,11 @@ const SWEEP_INTERVAL_MS = 60 * 1000; const registrations = new Map(); // jti → { exp, servers: Map } let sweeper = null; +let lastSweep = 0; +const LOOKUP_SWEEP_MIN_INTERVAL_MS = 1000; function sweep(now = Date.now()) { + lastSweep = now; for (const [jti, entry] of registrations) { if (entry.exp <= now) registrations.delete(jti); } @@ -90,13 +93,34 @@ export function registerRemoteMcps({ jti, exp, servers, now = Date.now() } = {}) } /** `{ url, headers }` for one server of one live registration, or null. Never the stored object. */ +// Runs on every relayed request (the relay re-authorizes each one), so the full sweep is throttled; +// the entry being asked about is always checked against its own expiry. export function lookupRemoteMcp(jti, name, now = Date.now()) { - sweep(now); + if (Math.abs(now - lastSweep) >= LOOKUP_SWEEP_MIN_INTERVAL_MS) sweep(now); const entry = typeof jti === "string" ? registrations.get(jti) : null; + if (entry && entry.exp <= now) { + registrations.delete(jti); + return null; + } const server = entry?.servers.get(String(name || "")); return server ? { url: server.url, headers: { ...server.headers } } : null; } +/** + * The `remote-mcp` hello's authorization, re-run on every forwarded request: the capability must + * verify, its signed `remoteMcps` claim must name the server, AND the daemon must hold a live + * registration for that name under the capability's jti. Both, never either: the claim alone has + * no credential behind it, and a registration alone was never granted to this bearer. + * Returns `{ url, headers }`; throws one fixed sentence otherwise. + */ +export function authorizeRemoteMcp(checked, name, now = Date.now()) { + const claims = checked?.ok ? checked.claims : null; + const granted = claims && validRemoteMcpName(name) && Array.isArray(claims.remoteMcps) && claims.remoteMcps.includes(name); + const server = granted ? lookupRemoteMcp(claims.jti, name, now) : null; + if (!server) throw new Error("remote MCP is not authorized for this run"); + return server; +} + export function clearRemoteMcps(jti) { registrations.delete(jti); if (!registrations.size) sweep(); diff --git a/src/mcp/remote-relay.js b/src/mcp/remote-relay.js new file mode 100644 index 00000000..9efd504e --- /dev/null +++ b/src/mcp/remote-relay.js @@ -0,0 +1,150 @@ +// The daemon-side relay behind the `remote-mcp` socket service (src/mcp/socket-server.js): an MCP +// Server on the container's socket whose tools are a header-bearing REMOTE MCP server (Composio +// user/agent, the toolboxes) dialled by the daemon with the real credential. The container side is +// the ordinary socket bridge holding only the signed run capability, so the credential never +// crosses into a container — not in a config file, not in an env var, not in a bundle. +// +// Modelled on the Enterprise composio-sdk bridge (src/ee/composio-sdk-bridge.js), which is the same +// idea for SDK sessions: connect a Client upstream, serve tools/list + tools/call downstream, and +// re-run the caller's authorization on EVERY forwarded request so an expired or revoked grant stops +// working mid-connection rather than at the next hello. +// +// Every failure surfaced from here is deliberately generic: an upstream error can quote a request +// header or a URL, and the refusal line reaches the engine's MCP log inside the container. +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { + CallToolRequestSchema, + CallToolResultSchema, + ListToolsRequestSchema, + ListToolsResultSchema, +} from "@modelcontextprotocol/sdk/types.js"; + +// The upstream ceiling for ONE forwarded request. The engine owns the real tool timeout (Claude's +// MCP_TOOL_TIMEOUT, Codex's tool_timeout_sec) and cancels through the relay when it gives up — +// which aborts the upstream call via the handler's signal — so this only has to be no tighter than +// either of them. Progress from upstream resets it. +export const RELAY_REQUEST_TIMEOUT_MS = 15 * 60 * 1000; +const CLIENT_INFO = { name: "channelgate-remote-relay", version: "1.0.0" }; + +/** An https URL with no embedded credentials, or a throw. The URL itself is never echoed. */ +export function validateRemoteUrl(value) { + let url; + try { + url = new URL(String(value || "")); + } catch { + throw new Error("remote MCP relay requires a valid URL"); + } + if (url.protocol !== "https:") throw new Error("remote MCP relay requires an HTTPS URL"); + if (url.username || url.password) throw new Error("remote MCP relay refuses credentials in the URL"); + return url; +} + +// The standard client pattern: Streamable HTTP first, and the legacy HTTP+SSE transport only when +// the server answered the Streamable initialize with a 4xx (the "this endpoint does not speak +// Streamable HTTP" signal: 404/405 on the POST). Anything else — a network failure, a 5xx — is a +// real failure and is not retried on a second transport. `fetch` is injectable for tests only. +export async function connectRemoteClient({ url, headers = {}, fetch = undefined } = {}) { + const target = validateRemoteUrl(url); + const requestInit = { headers: { ...headers } }; + const streamable = new Client(CLIENT_INFO, { capabilities: {} }); + try { + await streamable.connect(new StreamableHTTPClientTransport(target, { requestInit, ...(fetch ? { fetch } : {}) })); + return streamable; + } catch (error) { + await streamable.close().catch(() => {}); + const status = Number(error?.code); + if (!(status >= 400 && status < 500)) throw new Error("remote MCP server unavailable"); + } + const sse = new Client(CLIENT_INFO, { capabilities: {} }); + try { + await sse.connect(new SSEClientTransport(target, { + requestInit, + ...(fetch ? { fetch, eventSourceInit: { fetch } } : {}), + })); + return sse; + } catch { + await sse.close().catch(() => {}); + throw new Error("remote MCP server unavailable"); + } +} + +// Forwarded request options: the engine's cancellation aborts the upstream call, and upstream +// progress is re-emitted downstream under the ENGINE's progress token (the client allocates its +// own token for the upstream leg, so the engine's must not be forwarded as-is). +function forwardOptions(params, extra) { + const progressToken = params?._meta?.progressToken; + const options = { timeout: RELAY_REQUEST_TIMEOUT_MS, resetTimeoutOnProgress: true }; + if (extra?.signal) options.signal = extra.signal; + if (progressToken !== undefined && typeof extra?.sendNotification === "function") { + options.onprogress = (progress) => { + extra.sendNotification({ method: "notifications/progress", params: { ...progress, progressToken } }).catch?.(() => {}); + }; + } + return options; +} + +function upstreamParams(params) { + if (!params?._meta || params._meta.progressToken === undefined) return params; + const { progressToken: _dropped, ...meta } = params._meta; + const out = { ...params }; + if (Object.keys(meta).length) out._meta = meta; + else delete out._meta; + return out; +} + +/** The two forwarded methods, each re-authorized before it leaves the daemon. */ +export function createRelayHandlers(remote, authorize) { + if (typeof authorize !== "function") throw new Error("remote MCP relay requires an authorization check"); + return { + listTools: (params, extra) => { + authorize(); + return remote.request({ method: "tools/list", params: upstreamParams(params) }, ListToolsResultSchema, forwardOptions(params, extra)); + }, + callTool: (params, extra) => { + authorize(); + return remote.request({ method: "tools/call", params: upstreamParams(params) }, CallToolResultSchema, forwardOptions(params, extra)); + }, + }; +} + +/** + * Relay one remote MCP server onto `transport`. `authorize()` must throw when the grant no longer + * holds; it runs once before anything is dialled and again on every forwarded request. `remote` + * (a connected Client-like object) or `connect` may be injected by tests; production dials + * `url` with `headers` through connectRemoteClient. + */ +export async function runRemoteRelay({ url, headers = {}, transport, authorize, remote = null, connect = connectRemoteClient } = {}) { + if (typeof authorize !== "function") throw new Error("remote MCP relay requires an authorization check"); + if (!transport) throw new Error("remote MCP relay requires a transport"); + authorize(); + validateRemoteUrl(url); + const client = remote || await connect({ url, headers }); + const handlers = createRelayHandlers(client, authorize); + const instructions = typeof client.getInstructions === "function" ? client.getInstructions() : undefined; + const server = new Server( + { name: "channelgate-remote-relay", version: "1.0.0" }, + { capabilities: { tools: {} }, ...(typeof instructions === "string" && instructions ? { instructions } : {}) }, + ); + server.setRequestHandler(ListToolsRequestSchema, (request, extra) => handlers.listTools(request.params, extra)); + server.setRequestHandler(CallToolRequestSchema, (request, extra) => handlers.callTool(request.params, extra)); + // One upstream connection per downstream connection: whichever side ends, the other goes too. + let closed = false; + const closeBoth = () => { + if (closed) return; + closed = true; + server.close?.().catch?.(() => {}); + client.close?.()?.catch?.(() => {}); + }; + server.onclose = closeBoth; + client.onclose = closeBoth; + try { + await server.connect(transport); + } catch (error) { + closeBoth(); + throw error; + } + return { server, client, close: closeBoth }; +} diff --git a/src/mcp/socket-server.js b/src/mcp/socket-server.js index 0249b018..f09fc446 100644 --- a/src/mcp/socket-server.js +++ b/src/mcp/socket-server.js @@ -8,8 +8,9 @@ // // ── Wire protocol ──────────────────────────────────────────────────────────────────────────── // Client → one newline-terminated JSON line, then MCP JSON-RPC: -// {"channelgate":"hello","v":1,"service":"gateway"|"composio-sdk","cap":"", -// "engine":"claude"|"codex","toolset":"","progressReport":false,"args":[],"framed":true} +// {"channelgate":"hello","v":1,"service":"gateway"|"composio-sdk"|"remote-mcp", +// "cap":"","engine":"claude"|"codex","toolset":"","progressReport":false, +// "args":[],"framed":true} // Server → the MCP stream, preceded by one framing line when the client asked for `framed`: // {"channelgate":"ready","v":1,"service":"…"} — MCP frames follow on the same socket // A REFUSAL is always announced, then the socket closes: @@ -18,6 +19,20 @@ // hello, then pipe", and that still works — it just cannot tell a refusal apart from a malformed // MCP frame. ./socket-bridge.js (the reference client the image ships) opts in and prints the // reason on stderr, where the engine's MCP startup log shows it. +// Services: +// gateway the control plane (src/mcp/gateway-server.js), tool surface fixed by the claims. +// composio-sdk Enterprise SDK-mode Composio: args[0] is the session URL, which the capability's +// `composioSessions` must grant (src/ee/composio-sdk-bridge.js). +// remote-mcp a header-bearing remote MCP server (composio-user / composio-agent in token +// mode, makeitfuture-toolbox, make-toolbox) relayed by the daemon: args[0] is the +// server NAME. The capability's signed `remoteMcps` claim must name it AND the +// daemon's in-memory registry (./remote-mcp-registry.js) must hold a live +// registration for it under the capability's jti; the daemon then dials the real +// URL with the real headers (./remote-relay.js) and relays tools/list + tools/call, +// re-authorizing each. The container never holds the credential. +// Refusals from the two relaying services are fixed sentences ("remote MCP is not authorized for +// this run", "remote MCP unavailable", "composio bridge unavailable"): an upstream error can quote a +// request header or a URL, and none of that may reach a container. // A hello that does not arrive within 2 s, exceeds 64 KB, or carries a capability this daemon did // not sign is refused. Minting and verification now happen in ONE process, which kills the whole // aud/secret-skew class the child-process path had to defend against. @@ -28,8 +43,12 @@ // CG_TOOLSET ""|"memory-review"|… (hint; the signed claim wins) // CG_PROGRESS_REPORT "1" enables report_progress (hint; the signed claim wins) // CG_MCP_SOCKET default "/run/channelgate/mcp.sock" -// CG_MCP_SERVICE default "gateway"; "composio-sdk" for the SDK-mode Composio bridge -// argv forwarded as `args` — the composio-sdk session URL +// CG_MCP_SERVICE default "gateway"; "composio-sdk" for the SDK-mode Composio bridge; +// "remote-mcp" for a relayed header-bearing remote MCP server +// argv forwarded as `args` — the composio-sdk session URL, or the remote-mcp +// server name +// Codex reaches this bridge through ./secret-env-bridge.js (the capability comes from its 0600 +// bundle, never argv/env), which forwards CG_MCP_SERVICE and CG_MCP_SOCKET to it. // CG_APPROVAL_SECRET and CG_PORT deliberately do NOT appear: an in-process server calls the // daemon's own background/approval/restart handlers directly, so a container never holds the // loopback IPC credentials and there is no /internal/* route for it to reach. @@ -40,6 +59,8 @@ import { runtimeSocketDir, runtimeSocketFile } from "../config/paths.js"; import { verifyGatewayCapability } from "../gateway/mcp-capability.js"; import { createDirectDaemonIpc, createGatewayMcpServer, ctxFromClaims } from "./gateway-server.js"; import { runBridge } from "../ee/composio-sdk-bridge.js"; +import { authorizeRemoteMcp } from "./remote-mcp-registry.js"; +import { runRemoteRelay } from "./remote-relay.js"; const HELLO_TIMEOUT_MS = 2_000; const HELLO_MAX_BYTES = 64 * 1024; @@ -47,7 +68,8 @@ const HELLO_MAX_BYTES = 64 * 1024; // a truncation. A gateway root deep enough to blow it must degrade to "container runs can't reach // the control plane", never to a failed boot. const MAX_SOCKET_PATH_BYTES = 100; -const SERVICES = new Set(["gateway", "composio-sdk"]); +const SERVICES = new Set(["gateway", "composio-sdk", "remote-mcp"]); +const REFUSAL_BY_SERVICE = { "composio-sdk": "composio bridge unavailable", "remote-mcp": "remote MCP unavailable" }; let active = null; // { server, path, conns } @@ -69,7 +91,7 @@ function refuse(socket, reason) { * `secret()` is the daemon's own capability signing secret; `handlers` are the daemon-side * background/approval/restart functions the in-process tools call directly. */ -export function serveMcpConnection(socket, { handlers = {}, secret = () => process.env.CG_APPROVAL_SECRET || "", log = console } = {}) { +export function serveMcpConnection(socket, { handlers = {}, secret = () => process.env.CG_APPROVAL_SECRET || "", log = console, connectRemote = undefined } = {}) { socket.setNoDelay?.(true); let head = Buffer.alloc(0); let settled = false; @@ -116,6 +138,19 @@ export function serveMcpConnection(socket, { handlers = {}, secret = () => proce const checked = verify(); if (!checked.ok) return refuse(socket, `capability rejected (${checked.reason})`); + // remote-mcp is authorized BEFORE anything is dialled, and refused with one fixed sentence that + // names neither the server's URL nor why (unknown name, missing claim, expired registration). + let remoteName = ""; + let remoteTarget = null; + if (frame.service === "remote-mcp") { + remoteName = Array.isArray(frame.args) ? String(frame.args[0] || "").slice(0, 80) : ""; + try { + remoteTarget = authorizeRemoteMcp(checked, remoteName); + } catch (error) { + return refuse(socket, String(error?.message || "remote MCP is not authorized for this run")); + } + } + // Hold every byte that followed the hello until the MCP transport is attached: the transport // reads the socket's own "data" events, and there is no way to hand it bytes we already took. // An explicitly paused stream stays paused when a new "data" listener arrives, so the resume @@ -136,6 +171,15 @@ export function serveMcpConnection(socket, { handlers = {}, secret = () => proce const server = createGatewayMcpServer(ctx); await server.connect(transport); socket.once("close", () => { server.close?.().catch?.(() => {}); }); + } else if (frame.service === "remote-mcp") { + const { server } = await runRemoteRelay({ + url: remoteTarget.url, + headers: remoteTarget.headers, + transport, + authorize: () => authorizeRemoteMcp(verify(), remoteName), + ...(connectRemote ? { connect: connectRemote } : {}), + }); + socket.once("close", () => { server.close?.().catch?.(() => {}); }); } else { // SDK-mode Composio reads the organization SDK key from gateway settings, which a container // cannot see, so it rides this socket too. The session URL is not a secret; runBridge still @@ -147,8 +191,8 @@ export function serveMcpConnection(socket, { handlers = {}, secret = () => proce if (frame.framed === true) writeFrame(socket, { channelgate: "ready", v: 1, service: frame.service }); socket.resume(); } catch (e) { - // Deliberately terse for composio: upstream errors can quote request headers or URLs. - const reason = frame.service === "gateway" ? String(e?.message || "server error") : "composio bridge unavailable"; + // Deliberately terse for the relaying services: upstream errors can quote request headers or URLs. + const reason = frame.service === "gateway" ? String(e?.message || "server error") : REFUSAL_BY_SERVICE[frame.service]; log?.warn?.(`[gateway] MCP socket: ${frame.service} connection failed — ${reason}`); refuse(socket, reason); } @@ -167,7 +211,7 @@ export function serveMcpConnection(socket, { handlers = {}, secret = () => proce * to fail closed with a clear message of their own. * @returns {Promise<{server: import("node:net").Server, path: string}|null>} */ -export async function startMcpSocketServer({ handlers = {}, socketPath = runtimeSocketFile(), dir = runtimeSocketDir(), secret, log = console } = {}) { +export async function startMcpSocketServer({ handlers = {}, socketPath = runtimeSocketFile(), dir = runtimeSocketDir(), secret, log = console, connectRemote = undefined } = {}) { if (active) return active; try { if (Buffer.byteLength(socketPath) > MAX_SOCKET_PATH_BYTES) { @@ -184,7 +228,7 @@ export async function startMcpSocketServer({ handlers = {}, socketPath = runtime const server = net.createServer((socket) => { conns.add(socket); socket.once("close", () => conns.delete(socket)); - serveMcpConnection(socket, { handlers, secret, log }); + serveMcpConnection(socket, { handlers, secret, log, connectRemote }); }); server.on("error", (e) => log?.warn?.(`[gateway] MCP socket error: ${e?.message || e}`)); await new Promise((resolve, reject) => { diff --git a/test/mcp-remote-relay.test.js b/test/mcp-remote-relay.test.js new file mode 100644 index 00000000..3e2befd2 --- /dev/null +++ b/test/mcp-remote-relay.test.js @@ -0,0 +1,139 @@ +// The HTTP leg of the `remote-mcp` relay (src/mcp/remote-relay.js): the daemon dials a real MCP +// server over Streamable HTTP with the registered headers, falls back to HTTP+SSE only on a 4xx, +// and serves the result on a socket-side transport. The relay insists on https, so the tests hand +// it an https URL plus a fetch that rewrites the request onto a loopback http server — exactly the +// bytes a real server would see, without a TLS fixture. +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { connectRemoteClient, runRemoteRelay, validateRemoteUrl } from "../src/mcp/remote-relay.js"; + +function toolServer(seen) { + const server = new Server({ name: "remote", version: "1.0.0" }, { capabilities: { tools: {} } }); + server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [{ name: "echo", inputSchema: { type: "object" } }] })); + server.setRequestHandler(CallToolRequestSchema, (request) => { + seen.calls.push(request.params.arguments); + return { content: [{ type: "text", text: `echo:${request.params.arguments?.v}` }] }; + }); + return server; +} + +// mode "streamable": stateless Streamable HTTP on /mcp. mode "sse": POST /mcp answers 405 (an +// SSE-only server), GET /mcp opens the legacy stream, POST /messages carries client frames. +// mode "broken": every request is a 502. +async function startRemote(t, mode) { + const seen = { headers: [], calls: [] }; + const sse = new Map(); + const httpServer = http.createServer(async (req, res) => { + seen.headers.push({ method: req.method, path: req.url, key: req.headers["x-consumer-api-key"] || "", auth: req.headers.authorization || "" }); + if (mode === "broken") { res.writeHead(502).end("upstream says: key ck_leak"); return; } + if (mode === "streamable" && req.url === "/mcp") { + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + const server = toolServer(seen); + res.on("close", () => { transport.close(); server.close(); }); + await server.connect(transport); + await transport.handleRequest(req, res); + return; + } + if (mode === "sse" && req.url === "/mcp" && req.method === "GET") { + const transport = new SSEServerTransport("/messages", res); + sse.set(transport.sessionId, transport); + await toolServer(seen).connect(transport); + return; + } + if (mode === "sse" && req.url.startsWith("/messages")) { + const id = new URL(req.url, "http://x").searchParams.get("sessionId"); + await sse.get(id)?.handlePostMessage(req, res); + return; + } + res.writeHead(405).end(); + }); + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); + const { port } = httpServer.address(); + t.after(() => new Promise((resolve) => { httpServer.closeAllConnections?.(); httpServer.close(resolve); })); + const fetchVia = (input, init) => { + const url = new URL(typeof input === "string" ? input : input.href || input.url); + assert.equal(url.protocol, "https:", "the relay only ever asks for https"); + return fetch(`http://127.0.0.1:${port}${url.pathname}${url.search}`, init); + }; + return { seen, fetch: fetchVia }; +} + +test("the relay dials Streamable HTTP with the registered headers", async (t) => { + const remote = await startRemote(t, "streamable"); + const client = await connectRemoteClient({ url: "https://remote.example/mcp", headers: { "x-consumer-api-key": "ck_http_secret" }, fetch: remote.fetch }); + t.after(() => client.close()); + const tools = await client.listTools(); + assert.deepEqual(tools.tools.map((tool) => tool.name), ["echo"]); + assert.ok(remote.seen.headers.length > 0); + assert.ok(remote.seen.headers.every((h) => h.key === "ck_http_secret"), "every request carries the header"); +}); + +test("a 4xx from the Streamable endpoint falls back to HTTP+SSE, headers included", async (t) => { + const remote = await startRemote(t, "sse"); + const client = await connectRemoteClient({ url: "https://remote.example/mcp", headers: { Authorization: "Bearer tb_sse_secret" }, fetch: remote.fetch }); + t.after(() => client.close()); + const result = await client.callTool({ name: "echo", arguments: { v: 7 } }); + assert.equal(result.content[0].text, "echo:7"); + assert.deepEqual(remote.seen.headers[0], { method: "POST", path: "/mcp", key: "", auth: "Bearer tb_sse_secret" }, "Streamable HTTP was tried first"); + assert.ok(remote.seen.headers.some((h) => h.method === "GET" && h.auth === "Bearer tb_sse_secret"), "the SSE stream carries the header"); + assert.ok(remote.seen.headers.some((h) => h.path.startsWith("/messages") && h.auth === "Bearer tb_sse_secret"), "and so does every SSE POST"); +}); + +test("a 5xx is a failure, not a transport hint, and its message quotes nothing upstream", async (t) => { + const remote = await startRemote(t, "broken"); + await assert.rejects( + connectRemoteClient({ url: "https://remote.example/mcp", headers: { "x-consumer-api-key": "ck_leak" }, fetch: remote.fetch }), + (error) => error.message === "remote MCP server unavailable", + ); + assert.equal(remote.seen.headers.filter((h) => h.method === "GET").length, 0, "no SSE fallback on a 5xx"); +}); + +test("only https URLs without embedded credentials are dialled", async () => { + assert.throws(() => validateRemoteUrl("http://remote.example/mcp"), /HTTPS/); + assert.throws(() => validateRemoteUrl("https://user:pw@remote.example/mcp"), /credentials/); + assert.throws(() => validateRemoteUrl("not a url"), /valid URL/); + await assert.rejects(connectRemoteClient({ url: "http://remote.example/mcp" }), /HTTPS/); + assert.equal(validateRemoteUrl("https://eu1.make.com/mcp/server/abc").hostname, "eu1.make.com"); +}); + +test("runRemoteRelay authorizes before dialling and on every forwarded request, and forwards progress", async (t) => { + // A remote that reports progress on its tool call. + const upstream = new Server({ name: "remote", version: "1.0.0" }, { capabilities: { tools: {} } }); + upstream.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [{ name: "slow", inputSchema: { type: "object" } }] })); + upstream.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + const token = request.params._meta?.progressToken; + if (token !== undefined) await extra.sendNotification({ method: "notifications/progress", params: { progressToken: token, progress: 1, total: 2 } }); + return { content: [{ type: "text", text: "done" }] }; + }); + const [upClient, upServer] = InMemoryTransport.createLinkedPair(); + await upstream.connect(upServer); + const remote = new Client({ name: "relay", version: "1.0.0" }, { capabilities: {} }); + await remote.connect(upClient); + + let allowed = true; + let checks = 0; + const authorize = () => { checks += 1; if (!allowed) throw new Error("remote MCP is not authorized for this run"); }; + const [engineSide, relaySide] = InMemoryTransport.createLinkedPair(); + await assert.rejects(runRemoteRelay({ url: "https://remote.example/mcp", transport: relaySide, authorize: () => { throw new Error("no"); }, remote }), /no/); + const { close } = await runRemoteRelay({ url: "https://remote.example/mcp", transport: relaySide, authorize, remote }); + t.after(close); + const engine = new Client({ name: "engine", version: "1.0.0" }, { capabilities: {} }); + await engine.connect(engineSide); + const progress = []; + const result = await engine.callTool({ name: "slow", arguments: {} }, undefined, { onprogress: (p) => progress.push(p) }); + assert.equal(result.content[0].text, "done"); + assert.deepEqual(progress, [{ progress: 1, total: 2 }], "upstream progress reaches the engine under its own token"); + const before = checks; + await engine.listTools(); + assert.equal(checks, before + 1, "each forwarded request re-authorizes"); + allowed = false; + await assert.rejects(engine.listTools(), /not authorized/); + await engine.close(); +}); diff --git a/test/mcp-socket-server.test.js b/test/mcp-socket-server.test.js index b2bf93dc..3f77990c 100644 --- a/test/mcp-socket-server.test.js +++ b/test/mcp-socket-server.test.js @@ -39,8 +39,10 @@ function shortSocketDir() { return dir; } -function capability({ secret = SECRET, toolset = "", progressReport = false, author = AUTHOR, threadKey = "1700000000.000100", ttlMs } = {}) { +function capability({ secret = SECRET, toolset = "", progressReport = false, author = AUTHOR, threadKey = "1700000000.000100", ttlMs, remoteMcps, jti } = {}) { return mintGatewayCapability({ + ...(remoteMcps ? { remoteMcps } : {}), + ...(jti ? { jti } : {}), secret, channelId: CHANNEL, slug: SLUG, @@ -69,10 +71,10 @@ function rawExchange(socketPath, line) { // One listener per test, torn down with the test: the module keeps a single active server, so a // leaked one from a failed test would silently serve the next test's assertions. -async function serverOn(t, handlers = {}) { +async function serverOn(t, handlers = {}, { connectRemote } = {}) { const dir = shortSocketDir(); const socketPath = path.join(dir, "mcp.sock"); - const active = await startMcpSocketServer({ handlers, socketPath, dir, log: { log() {}, warn() {} } }); + const active = await startMcpSocketServer({ handlers, socketPath, dir, log: { log() {}, warn() {} }, ...(connectRemote ? { connectRemote } : {}) }); assert.ok(active, "the socket server must bind under a short /tmp path"); t.after(() => stopMcpSocketServer()); return socketPath; @@ -127,10 +129,10 @@ test("a capability this daemon did not sign is rejected — a bearer is the only // The end-to-end shape a container actually uses: the engine spawns cg-mcp-bridge as an ordinary // stdio MCP server, the bridge dials the socket, and the MCP client never learns the difference. -async function withBridgeClient(socketPath, env, fn) { +async function withBridgeClient(socketPath, env, fn, bridgeArgs = []) { const transport = new StdioClientTransport({ command: process.execPath, - args: [BRIDGE], + args: [BRIDGE, ...bridgeArgs], stderr: "pipe", env: { PATH: process.env.PATH || "", @@ -267,3 +269,124 @@ test("gateway capability alone cannot select an arbitrary SDK session on the dae })}\n`); assert.equal(JSON.parse(reply.trim()).reason, "composio bridge unavailable"); }); + +// ── remote-mcp: header-bearing remote MCPs relayed by the daemon (container-secrets P1) ──────── +// The container holds only the capability; the daemon's registry holds the URL and headers. The +// "remote" here is a real MCP server behind an in-memory transport, reached through the relay's +// injectable connect — the HTTP leg itself is covered in mcp-remote-relay.test.js. +const { registerRemoteMcps, clearRemoteMcps } = await import("../src/mcp/remote-mcp-registry.js"); +const { Server } = await import("@modelcontextprotocol/sdk/server/index.js"); +const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js"); +const { CallToolRequestSchema, ListToolsRequestSchema } = await import("@modelcontextprotocol/sdk/types.js"); + +function fakeRemote() { + const dials = []; + const calls = []; + const connectRemote = async ({ url, headers }) => { + dials.push({ url, headers: { ...headers } }); + const server = new Server({ name: "fake-remote", version: "1.0.0" }, { capabilities: { tools: {} }, instructions: "remote instructions" }); + server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: [{ name: "COMPOSIO_SEARCH_TOOLS", description: "search", inputSchema: { type: "object", properties: { q: { type: "string" } } } }], + })); + server.setRequestHandler(CallToolRequestSchema, (request) => { + calls.push(request.params); + return { content: [{ type: "text", text: `echo:${request.params.arguments?.q}` }] }; + }); + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + await server.connect(serverSide); + const client = new Client({ name: "relay-under-test", version: "1.0.0" }, { capabilities: {} }); + await client.connect(clientSide); + return client; + }; + return { dials, calls, connectRemote }; +} + +const RELAYED = { + "composio-user": { url: "https://connect.composio.dev/mcp", headers: { "x-consumer-api-key": "ck_relay_user_secret" } }, + "make-toolbox": { url: "https://eu1.make.com/mcp/server/abc", headers: { Authorization: "Bearer mk_relay_secret" } }, +}; +const hello = (cap, name) => `${JSON.stringify({ channelgate: "hello", v: 1, service: "remote-mcp", cap, args: [name] })}\n`; + +test("remote-mcp relays tools/list and tools/call through the reference bridge with headers only the daemon holds", async (t) => { + const remote = fakeRemote(); + const socketPath = await serverOn(t, {}, { connectRemote: remote.connectRemote }); + const jti = "relay-jti-list-call"; + const cap = capability({ remoteMcps: ["composio-user"], jti }); + registerRemoteMcps({ jti, exp: Date.now() + 60_000, servers: { "composio-user": RELAYED["composio-user"] } }); + t.after(() => clearRemoteMcps(jti)); + + const answer = await withBridgeClient(socketPath, { CG_GATEWAY_CAPABILITY: cap, CG_MCP_SERVICE: "remote-mcp", CG_ENGINE: "claude" }, async (client) => ({ + tools: (await client.listTools()).tools.map((tool) => tool.name), + call: await client.callTool({ name: "COMPOSIO_SEARCH_TOOLS", arguments: { q: "gmail" } }), + instructions: client.getInstructions(), + }), ["composio-user"]); + + assert.deepEqual(answer.tools, ["COMPOSIO_SEARCH_TOOLS"]); + assert.equal(answer.call.content[0].text, "echo:gmail"); + assert.equal(answer.instructions, "remote instructions", "the remote's instructions reach the engine"); + assert.deepEqual(remote.dials, [{ url: RELAYED["composio-user"].url, headers: RELAYED["composio-user"].headers }], "the DAEMON dialled with the registered headers"); + assert.deepEqual(remote.calls.map((p) => p.name), ["COMPOSIO_SEARCH_TOOLS"]); +}); + +test("remote-mcp refuses an unregistered jti, a name outside the claim, and an expired registration", async (t) => { + const remote = fakeRemote(); + const socketPath = await serverOn(t, {}, { connectRemote: remote.connectRemote }); + const refusal = async (line) => JSON.parse((await rawExchange(socketPath, line)).trim()); + const REFUSED = { channelgate: "error", reason: "remote MCP is not authorized for this run" }; + + // Claimed but never registered under this jti. + assert.deepEqual(await refusal(hello(capability({ remoteMcps: ["composio-user"], jti: "relay-jti-unregistered" }), "composio-user")), REFUSED); + + // Registered under the jti, but the signed claim does not name it — both must hold. + const jti = "relay-jti-claim-scope"; + registerRemoteMcps({ jti, exp: Date.now() + 60_000, servers: RELAYED }); + t.after(() => clearRemoteMcps(jti)); + const narrow = capability({ remoteMcps: ["composio-user"], jti }); + assert.deepEqual(await refusal(hello(narrow, "make-toolbox")), REFUSED); + // No claim at all (an old-shaped token with the same jti): nothing is relayed. + assert.deepEqual(await refusal(hello(capability({ jti }), "composio-user")), REFUSED); + // A name that is neither claimed nor registered, and a malformed one. + assert.deepEqual(await refusal(hello(narrow, "makeitfuture-toolbox")), REFUSED); + assert.deepEqual(await refusal(hello(narrow, "../../etc/passwd")), REFUSED); + + // A registration that has expired (its capability outlived it only in this test). + const shortJti = "relay-jti-expiring"; + registerRemoteMcps({ jti: shortJti, exp: Date.now() + 30, servers: { "composio-user": RELAYED["composio-user"] } }); + await new Promise((r) => setTimeout(r, 1_100)); // past the entry's exp and the lookup-sweep throttle + assert.deepEqual(await refusal(hello(capability({ remoteMcps: ["composio-user"], jti: shortJti }), "composio-user")), REFUSED); + + // An expired CAPABILITY is refused at the signature/lifetime check, before any lookup. + const expired = capability({ remoteMcps: ["composio-user"], jti: "relay-jti-expired-cap", ttlMs: 1 }); + await new Promise((r) => setTimeout(r, 5)); + assert.match((await refusal(hello(expired, "composio-user"))).reason, /capability rejected/); + + assert.equal(remote.dials.length, 0, "no refused hello ever dialled the remote"); + for (const secret of ["ck_relay_user_secret", "mk_relay_secret", "composio.dev", "make.com"]) { + assert.ok(!JSON.stringify(REFUSED).includes(secret)); + } +}); + +test("remote-mcp re-authorizes every forwarded request: a revoked registration stops an open relay", async (t) => { + const remote = fakeRemote(); + const socketPath = await serverOn(t, {}, { connectRemote: remote.connectRemote }); + const jti = "relay-jti-revoked-mid"; + registerRemoteMcps({ jti, exp: Date.now() + 60_000, servers: { "make-toolbox": RELAYED["make-toolbox"] } }); + const cap = capability({ remoteMcps: ["make-toolbox"], jti }); + await withBridgeClient(socketPath, { CG_GATEWAY_CAPABILITY: cap, CG_MCP_SERVICE: "remote-mcp" }, async (client) => { + assert.equal((await client.listTools()).tools.length, 1); + clearRemoteMcps(jti); + await assert.rejects(client.callTool({ name: "COMPOSIO_SEARCH_TOOLS", arguments: { q: "x" } }), /not authorized/); + }, ["make-toolbox"]); + assert.equal(remote.calls.length, 0, "the revoked call never reached the remote"); +}); + +test("a remote that cannot be dialled is refused with a fixed sentence that quotes nothing upstream", async (t) => { + const socketPath = await serverOn(t, {}, { + connectRemote: async ({ url, headers }) => { throw new Error(`boom ${url} ${JSON.stringify(headers)}`); }, + }); + const jti = "relay-jti-dial-fail"; + registerRemoteMcps({ jti, exp: Date.now() + 60_000, servers: { "composio-user": RELAYED["composio-user"] } }); + t.after(() => clearRemoteMcps(jti)); + const reply = await rawExchange(socketPath, hello(capability({ remoteMcps: ["composio-user"], jti }), "composio-user")); + assert.deepEqual(JSON.parse(reply.trim()), { channelgate: "error", reason: "remote MCP unavailable" }); +}); From 6792e6443c45ab59a2f2bb06ef9788546bfee2ce Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Sat, 26 Sep 2026 18:06:37 +0300 Subject: [PATCH 03/33] feat: give containerized Claude runs relayed remote MCPs, not tokens On an isolated target the header-bearing remotes (composio-user and composio-agent in token/endpoint mode, makeitfuture-toolbox, make-toolbox) become socket-bridge entries selecting the `remote-mcp` service. The capability is minted with a fixed jti and the `remoteMcps` names, and the real URL + headers are registered daemon-side under that jti, so the cg-mcp-*.json in the artifact dir carries no credential. Host targets keep the exact direct http entries. The warm-pool fingerprint gains a value-free digest of each relayed URL and header, so a rotated token still retires the warm process, and it now normalizes the capability on every socket-bridged entry, not only the gateway's. A remote whose URL the relay cannot dial (a non-https override) is dropped from an isolated run and reported, never handed over. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Tiberiu Socaci --- src/gateway/mcp.js | 143 +++++++++++++++++----- src/gateway/run-engine-mcp.js | 21 +++- src/mcp/remote-mcp-registry.js | 5 + test/mcp-config.test.js | 107 ++++++++++++++-- test/progress-report-availability.test.js | 4 +- test/run-engine-mcp.test.js | 25 ++++ 6 files changed, 258 insertions(+), 47 deletions(-) diff --git a/src/gateway/mcp.js b/src/gateway/mcp.js index eceb13ae..9444228b 100644 --- a/src/gateway/mcp.js +++ b/src/gateway/mcp.js @@ -17,15 +17,28 @@ // checkout, and their env carries the signed capability and nothing else. A container must never // receive CG_PORT / CG_APPROVAL_SECRET (there is no /internal/* route it could reach), nor // CHANNELGATE_DIR / CG_FS_ROOT / CG_WORKSPACE_DIR / PATH (host paths that do not exist inside it). -// Remote http entries are the same with or without a target — the engine dials those itself. The -// local runtime (daemon-internal turns) or no target at all produces the plain stdio form. +// The local runtime (daemon-internal turns) or no target at all produces the plain stdio form. +// +// RELAYED REMOTES (container-secrets P1): the four header-bearing remote servers — composio-user +// and composio-agent in token/endpoint mode, makeitfuture-toolbox, make-toolbox — are plain +// `{type:"http", url, headers}` entries on a host target (the engine dials them itself, credential +// in the file). On an ISOLATED target that file lives in the artifact dir every process in the +// container can read, so there they become the SAME socket-bridge entry the SDK mode uses, with +// `CG_MCP_SERVICE=remote-mcp` and the server name as the bridge argument: the container holds only +// the signed capability (whose `remoteMcps` claim names the servers), and the real URL + headers +// are registered in the daemon's in-memory registry (src/mcp/remote-mcp-registry.js) under the +// capability's jti, for the capability's lifetime. The daemon dials them (src/mcp/remote-relay.js). +// `buildMcpRuntimePayload` also returns a digest of the relayed URL + headers so the warm pool can +// still retire a process when a token rotates (src/gateway/run-engine-mcp.js). +import { createHash, randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; import { composioUrl, toolboxUrl } from "./mcp-catalog.js"; import { gatewayRoot } from "../config/paths.js"; import { requireAdapter } from "../engines/registry.js"; import { runtimeSupports } from "../runtimes/contract.js"; import { requireComposioSdkEntitlement } from "../ee/composio-entitlement.js"; -import { mintGatewayCapability } from "./mcp-capability.js"; +import { mintGatewayCapability, mintedCapabilityClaims } from "./mcp-capability.js"; +import { isRelayableUrl, registerRemoteMcps } from "../mcp/remote-mcp-registry.js"; const GATEWAY_PATH = fileURLToPath(new URL("../mcp/gateway-server.js", import.meta.url)); const COMPOSIO_SDK_BRIDGE_PATH = fileURLToPath(new URL("../ee/composio-sdk-bridge.js", import.meta.url)); @@ -53,23 +66,33 @@ function composioServer(endpoint, legacyToken, { socketBridge = null, gatewayCap default_tools_approval_mode: "approve", }; } - if (endpoint?.url) { - return { - type: "http", - url: endpoint.url, - headers: endpoint.headers || {}, - default_tools_approval_mode: "approve", - }; - } - if (!legacyToken) return null; + const remote = composioHttpTarget(endpoint, legacyToken); + if (!remote) return null; return { type: "http", - url: composioUrl(), - headers: { "x-consumer-api-key": legacyToken }, + url: remote.url, + headers: remote.headers, default_tools_approval_mode: "approve", }; } +// The header-bearing form of a Composio identity (an explicit endpoint, or the legacy token against +// the hosted URL), or null for SDK mode / no credential. One answer for both the host http entry +// above and the relay registration an isolated target gets instead. +function composioHttpTarget(endpoint, legacyToken) { + if (endpoint?.mode === "sdk" && endpoint.url) return null; + if (endpoint?.url) return { url: endpoint.url, headers: endpoint.headers || {} }; + if (!legacyToken) return null; + return { url: composioUrl(), headers: { "x-consumer-api-key": legacyToken } }; +} + +// A stable, value-free digest of one relayed server's URL + headers: the warm pool's fingerprint +// needs to CHANGE when a token rotates, but must never contain the token. +function relayDigestOf({ url, headers }) { + const sorted = Object.keys(headers).sort().map((name) => [name, headers[name]]); + return `sha256:${createHash("sha256").update(JSON.stringify([url, sorted])).digest("hex")}`; +} + // Which Composio identities THIS run actually injects — the one predicate behind both the server // map below and the per-run identity line run.js prepends to the prompt. It lives here on purpose: // the line names `composio-user` / `composio-agent`, the exact keys assigned a few dozen lines @@ -114,9 +137,51 @@ export function composioIdentityPreamble({ user = false, agent = false } = {}) { return ""; } -export async function buildMcpConfig({ composioUserEndpoint = null, composioEndpoint = null, composioUserToken = "", composioToken = "", toolboxToken = "", makeToolboxUrl = "", makeToolboxKey = "", channelId = "", slug = "", authorId = "", threadKey = "", origin = "", progressReport = false, engine = "claude", principalTrusted = true, gatewayFsRoot = "", gatewayWorkspaceRoot = "", toolset = "", target = null, ttlMs = undefined } = {}) { +// The per-run MCP config as the JSON string every existing caller writes to a file. +export async function buildMcpConfig(options = {}) { + return (await buildMcpRuntimePayload(options)).configJson; +} + +/** + * The per-run MCP payload plus what a caller needs beyond the JSON: `relayDigest` (per relayed + * server, a digest of its URL + headers — `{}` on a host target, where the headers are in the JSON + * itself) for the warm-pool fingerprint, the signed `gatewayCapability`, and `relayedMcps` (the + * names registered with the daemon's relay for this capability) and `rejectedRemotes` (a remote an + * isolated run could not be given, `[{ name, reason }]`, for the caller's rejected-MCP note). + */ +export async function buildMcpRuntimePayload({ composioUserEndpoint = null, composioEndpoint = null, composioUserToken = "", composioToken = "", toolboxToken = "", makeToolboxUrl = "", makeToolboxKey = "", channelId = "", slug = "", authorId = "", threadKey = "", origin = "", progressReport = false, engine = "claude", principalTrusted = true, gatewayFsRoot = "", gatewayWorkspaceRoot = "", toolset = "", target = null, ttlMs = undefined } = {}) { // Identity claim — fail closed on garbage instead of silently signing as Claude. const normalizedEngine = requireAdapter(engine || "claude").id; + // Fail closed on an unknown capability key; a target that is absent or host-backed is today's path. + const isolated = target && runtimeSupports(target, "isolated") ? target : null; + const gatewayHelper = isolated ? isolated.runtime.helperCommand(isolated, "gateway-mcp") : null; + // Same predicate the prompt's identity line is built from — one answer to "which identities does + // this run have?", never two that can drift apart. + const identities = composioIdentitiesForRun({ principalTrusted, composioUserEndpoint, composioUserToken, composioEndpoint, composioToken }); + // The header-bearing remotes this run gets, in the order their entries appear below. Host: plain + // http entries. Isolated: relayed through the daemon, so their names go into the signed claim and + // their URL + headers into the daemon's registry — never into the JSON. + const httpRemotes = {}; + const userRemote = identities.user ? composioHttpTarget(composioUserEndpoint, composioUserToken) : null; + if (userRemote) httpRemotes["composio-user"] = userRemote; + const sharedRemote = identities.agent ? composioHttpTarget(composioEndpoint, composioToken) : null; + if (sharedRemote) httpRemotes["composio-agent"] = sharedRemote; + if (toolboxToken) httpRemotes["makeitfuture-toolbox"] = { url: toolboxUrl(), headers: { Authorization: `Bearer ${toolboxToken}` } }; + if (makeToolboxUrl && makeToolboxKey) httpRemotes["make-toolbox"] = { url: makeToolboxUrl, headers: { Authorization: `Bearer ${makeToolboxKey}` } }; + // The relay dials https only. A remote an operator pointed at plain http (COMPOSIO_MCP_URL / + // TOOLBOX_MCP_URL overrides) cannot be relayed, and handing its credential to the container + // instead is exactly what this path exists to stop — so it is dropped from an isolated run and + // REPORTED through the same rejected-MCP note an unadmittable catalog selection gets. + const rejectedRemotes = []; + if (isolated) { + for (const name of Object.keys(httpRemotes)) { + if (isRelayableUrl(httpRemotes[name].url)) continue; + delete httpRemotes[name]; + rejectedRemotes.push({ name, reason: "its URL is not https, so the gateway cannot relay it into a container" }); + } + } + const relayedMcps = isolated ? Object.keys(httpRemotes) : []; + const jti = randomUUID(); const gatewayCapability = mintGatewayCapability({ secret: process.env.CG_APPROVAL_SECRET || "", channelId, @@ -134,11 +199,25 @@ export async function buildMcpConfig({ composioUserEndpoint = null, composioEndp // its own to read these from (src/mcp/socket-server.js). toolset, progressReport, + ...(relayedMcps.length ? { remoteMcps: relayedMcps } : {}), + jti, ...(ttlMs ? { ttlMs } : {}), }); - // Fail closed on an unknown capability key; a target that is absent or host-backed is today's path. - const isolated = target && runtimeSupports(target, "isolated") ? target : null; - const gatewayHelper = isolated ? isolated.runtime.helperCommand(isolated, "gateway-mcp") : null; + // Registered under the capability's own jti and expiry, in THIS process — the daemon that also + // serves the socket. Nothing else holds the credential for an isolated run. + const relayDigest = {}; + if (relayedMcps.length) { + registerRemoteMcps({ jti, exp: mintedCapabilityClaims(gatewayCapability).exp, servers: httpRemotes }); + for (const name of relayedMcps) relayDigest[name] = relayDigestOf(httpRemotes[name]); + } + // An isolated run's entry for a relayed remote: the gateway's own socket bridge, selecting the + // remote-mcp service and naming the server. The capability is the only authority it carries. + const relayServer = (name) => ({ + command: gatewayHelper.command, + args: [...(gatewayHelper.args || []), name], + env: { CG_MCP_SERVICE: "remote-mcp", CG_GATEWAY_CAPABILITY: gatewayCapability }, + default_tools_approval_mode: "approve", + }); const servers = { gateway: isolated ? { command: gatewayHelper.command, @@ -178,22 +257,18 @@ export async function buildMcpConfig({ composioUserEndpoint = null, composioEndp }, }; const composioOpts = { socketBridge: gatewayHelper, gatewayCapability }; - // Same predicate the prompt's identity line is built from — one answer to "which identities does - // this run have?", never two that can drift apart. - const identities = composioIdentitiesForRun({ principalTrusted, composioUserEndpoint, composioUserToken, composioEndpoint, composioToken }); - const userComposio = identities.user ? composioServer(composioUserEndpoint, composioUserToken, composioOpts) : null; - if (userComposio) servers["composio-user"] = userComposio; - const sharedComposio = identities.agent ? composioServer(composioEndpoint, composioToken, composioOpts) : null; - if (sharedComposio) servers["composio-agent"] = sharedComposio; - if (toolboxToken) { - servers["makeitfuture-toolbox"] = { type: "http", url: toolboxUrl(), headers: { Authorization: `Bearer ${toolboxToken}` } }; + for (const [name, endpoint, legacyToken, present] of [ + ["composio-user", composioUserEndpoint, composioUserToken, identities.user], + ["composio-agent", composioEndpoint, composioToken, identities.agent], + ]) { + if (!present) continue; + if (rejectedRemotes.some((rejected) => rejected.name === name)) continue; + const entry = isolated && httpRemotes[name] ? relayServer(name) : composioServer(endpoint, legacyToken, composioOpts); + if (entry) servers[name] = entry; } - if (makeToolboxUrl && makeToolboxKey) { - servers["make-toolbox"] = { - type: "http", - url: makeToolboxUrl, - headers: { Authorization: `Bearer ${makeToolboxKey}` }, - }; + for (const name of ["makeitfuture-toolbox", "make-toolbox"]) { + const remote = httpRemotes[name]; + if (remote) servers[name] = isolated ? relayServer(name) : { type: "http", url: remote.url, headers: remote.headers }; } - return JSON.stringify({ mcpServers: servers }); + return { configJson: JSON.stringify({ mcpServers: servers }), relayDigest, gatewayCapability, relayedMcps, rejectedRemotes }; } diff --git a/src/gateway/run-engine-mcp.js b/src/gateway/run-engine-mcp.js index 3ec75c45..7903352d 100644 --- a/src/gateway/run-engine-mcp.js +++ b/src/gateway/run-engine-mcp.js @@ -1,7 +1,7 @@ // Build the MCP payload and signed gateway capability for the engine that will actually spawn. // A fallback is a new authority decision, not merely another runner: engine-scoped mutations are // authorized from this claim, so Claude's capability must never be reused by a Codex fallback. -import { buildMcpConfig } from "./mcp.js"; +import { buildMcpRuntimePayload } from "./mcp.js"; import { requireAdapter } from "../engines/registry.js"; import { requirePluginRuntime } from "./plugin-runtime.js"; import { safeCodexMcpDefinition } from "./mcp-discovery.js"; @@ -25,8 +25,11 @@ export async function buildEngineMcpRuntime({ clean = false, engine = "claude", // reports in the thread so the drop is visible to the admin who has to fix the selection. const optional = await requireAdapter(engine).resolveOptionalMcpConfig?.(allowedMcps) || {}; const optionalServers = optional.servers || {}; - const rejectedMcps = Array.isArray(optional.rejected) ? optional.rejected : []; - const parsed = JSON.parse(await buildMcpConfig({ ...identity, engine, target })); + const payload = await buildMcpRuntimePayload({ ...identity, engine, target }); + // A built-in remote an isolated run could not be relayed (a non-https override) is reported the + // same way as an unadmittable selection: dropped, named, never silently absent. + const rejectedMcps = [...(Array.isArray(optional.rejected) ? optional.rejected : []), ...(payload.rejectedRemotes || [])]; + const parsed = JSON.parse(payload.configJson); for (const [name, definition] of Object.entries(optionalServers)) { if (Object.hasOwn(parsed.mcpServers, name)) throw new Error("Selected MCP server conflicts with a built-in identity."); parsed.mcpServers[name] = definition; @@ -48,8 +51,14 @@ export async function buildEngineMcpRuntime({ clean = false, engine = "claude", // the warm process on every message. Replace only that token in the FINGERPRINT view with its // stable authority scope plus a bounded renewal bucket. Actual argv/config still receives the // authentic signed token; author/origin/engine/trust changes continue to force a safe drain. + // + // The same token also rides every socket-bridged entry (the SDK-mode Composio sessions and, on an + // isolated target, the relayed remotes), so it is replaced wherever it appears. A relayed remote's + // credential is NOT in the JSON any more (it is in the daemon's relay registry), so its URL + + // header digest is added instead: a rotated Composio/toolbox token must still retire the warm + // process, exactly as it did when the token itself was part of the JSON. const fingerprintView = structuredClone(parsed); - fingerprintView.mcpServers.gateway.env.CG_GATEWAY_CAPABILITY = JSON.stringify({ + const stableCapability = JSON.stringify({ channelId: identity.channelId || "", slug: identity.slug || "", authorId: identity.authorId || "", @@ -59,6 +68,10 @@ export async function buildEngineMcpRuntime({ clean = false, engine = "claude", principalTrusted: identity.principalTrusted !== false, renewalBucket: Math.floor(Number(fingerprintNow) / CAPABILITY_FINGERPRINT_BUCKET_MS), }); + for (const server of Object.values(fingerprintView.mcpServers)) { + if (server?.env?.CG_GATEWAY_CAPABILITY === gatewayCapability) server.env.CG_GATEWAY_CAPABILITY = stableCapability; + } + if (Object.keys(payload.relayDigest || {}).length) fingerprintView.relayDigest = payload.relayDigest; return { mcpConfigJson, mcpConfigFingerprint: JSON.stringify(fingerprintView), diff --git a/src/mcp/remote-mcp-registry.js b/src/mcp/remote-mcp-registry.js index 289307ab..0783ee7c 100644 --- a/src/mcp/remote-mcp-registry.js +++ b/src/mcp/remote-mcp-registry.js @@ -43,6 +43,11 @@ function ensureSweeper() { sweeper.unref?.(); } +/** Whether the relay would accept this URL at all (https, no embedded credentials, bounded). */ +export function isRelayableUrl(value) { + return validUrl(value); +} + function validUrl(value) { if (typeof value !== "string" || !value || value.length > MAX_URL_LENGTH) return false; try { diff --git a/test/mcp-config.test.js b/test/mcp-config.test.js index 37204299..e17d25f8 100644 --- a/test/mcp-config.test.js +++ b/test/mcp-config.test.js @@ -11,7 +11,7 @@ const { workspaceRoot } = await import("../src/config/paths.js"); const { allowedFsRoot } = await import("../src/web/security.js"); const { createFakeRuntime } = await import("./fixtures/fake-runtime-backend.js"); const { localRuntimeTarget } = await import("../src/engines/runtime-target.js"); -const buildMcpConfig = (options = {}) => buildRawMcpConfig({ +const withIdentity = (options = {}) => ({ channelId: "C_CONFIG", slug: "mcp-config-test", authorId: "U_CONFIG", @@ -21,6 +21,7 @@ const buildMcpConfig = (options = {}) => buildRawMcpConfig({ gatewayWorkspaceRoot: workspaceRoot(), ...options, }); +const buildMcpConfig = (options = {}) => buildRawMcpConfig(withIdentity(options)); test("Claude MCP config exposes personal and agent Composio with separate credentials", async () => { const config = JSON.parse(await buildMcpConfig({ @@ -196,16 +197,108 @@ test("SDK-mode Composio rides the same socket bridge in a container, and the pla assert.equal(entry.env.CHANNELGATE_DIR, undefined); }); -test("remote http MCP entries are identical on both backends", async () => { +// Container-secrets P1: on an isolated target the four header-bearing remotes are relayed by the +// daemon. The config (a file in the artifact dir every container process can read) carries the +// socket bridge + the signed capability only; the URL and header live in the daemon's registry. +test("an isolated target relays every header-bearing remote through the socket bridge, with no token in the config", async () => { + const { buildMcpRuntimePayload } = await import("../src/gateway/mcp.js"); + const { lookupRemoteMcp } = await import("../src/mcp/remote-mcp-registry.js"); const fake = createFakeRuntime(); - const opts = { toolboxToken: "tb-1", composioToken: "ck_shared", makeToolboxUrl: "https://eu1.make.com/mcp/server/x", makeToolboxKey: "mk" }; - const host = JSON.parse(await buildMcpConfig(opts)); - const isolated = JSON.parse(await buildMcpConfig({ ...opts, target: fake.target() })); - for (const name of ["makeitfuture-toolbox", "composio-agent", "make-toolbox"]) { - assert.deepEqual(isolated.mcpServers[name], host.mcpServers[name], name); + const opts = { + composioUserToken: "ck_user_relay_secret", + composioToken: "ck_shared_relay_secret", + toolboxToken: "tb-relay-secret", + makeToolboxUrl: "https://eu1.make.com/mcp/server/x", + makeToolboxKey: "mk-relay-secret", + }; + const payload = await buildMcpRuntimePayload(withIdentity({ ...opts, target: fake.target() })); + const json = payload.configJson; + for (const secret of Object.values(opts).filter((v) => !v.startsWith("https://"))) { + assert.ok(!json.includes(secret), "no relayed credential reaches the container's config file"); } + assert.doesNotMatch(json, /x-consumer-api-key|Bearer |"headers"/); + const servers = JSON.parse(json).mcpServers; + const names = ["composio-user", "composio-agent", "makeitfuture-toolbox", "make-toolbox"]; + const cap = servers.gateway.env.CG_GATEWAY_CAPABILITY; + for (const name of names) { + assert.deepEqual(servers[name], { + command: "/usr/local/bin/node", + args: ["/opt/channelgate/bin/cg-mcp-bridge.js", name], + env: { CG_MCP_SERVICE: "remote-mcp", CG_GATEWAY_CAPABILITY: cap }, + default_tools_approval_mode: "approve", + }, name); + } + assert.deepEqual(payload.relayedMcps, names); + assert.equal(payload.gatewayCapability, cap); + + // The signed claim names exactly those servers, and the daemon's registry holds the real URL and + // header under the capability's own jti — the one place the credential exists for this run. + const claims = verifyGatewayCapability(cap, { secret: process.env.CG_APPROVAL_SECRET }).claims; + assert.deepEqual(claims.remoteMcps, names); + assert.deepEqual(lookupRemoteMcp(claims.jti, "composio-user"), { url: "https://connect.composio.dev/mcp", headers: { "x-consumer-api-key": "ck_user_relay_secret" } }); + assert.deepEqual(lookupRemoteMcp(claims.jti, "composio-agent").headers, { "x-consumer-api-key": "ck_shared_relay_secret" }); + assert.deepEqual(lookupRemoteMcp(claims.jti, "makeitfuture-toolbox").headers, { Authorization: "Bearer tb-relay-secret" }); + assert.deepEqual(lookupRemoteMcp(claims.jti, "make-toolbox"), { url: "https://eu1.make.com/mcp/server/x", headers: { Authorization: "Bearer mk-relay-secret" } }); + // The fingerprint digest names every relayed server and carries no value. + assert.deepEqual(Object.keys(payload.relayDigest), names); + for (const digest of Object.values(payload.relayDigest)) assert.match(digest, /^sha256:[0-9a-f]{64}$/); + assert.ok(!JSON.stringify(payload.relayDigest).includes("secret")); }); +test("a non-SDK Composio endpoint with its own headers is relayed too, while SDK mode keeps its own service", async () => { + const { lookupRemoteMcp } = await import("../src/mcp/remote-mcp-registry.js"); + const fake = createFakeRuntime(); + const config = JSON.parse(await buildMcpConfig({ + composioUserEndpoint: { url: "https://composio.example/mcp", headers: { "x-consumer-api-key": "endpoint-secret" } }, + composioEndpoint: { mode: "sdk", url: "https://backend.composio.dev/api/v3/tool_router/s/mcp" }, + target: fake.target(), + })); + assert.equal(config.mcpServers["composio-user"].env.CG_MCP_SERVICE, "remote-mcp"); + assert.equal(config.mcpServers["composio-agent"].env.CG_MCP_SERVICE, "composio-sdk"); + assert.doesNotMatch(JSON.stringify(config), /endpoint-secret/); + const claims = verifyGatewayCapability(config.mcpServers.gateway.env.CG_GATEWAY_CAPABILITY, { secret: process.env.CG_APPROVAL_SECRET }).claims; + assert.deepEqual(claims.remoteMcps, ["composio-user"], "an SDK session is granted by composioSessions, not relayed"); + assert.deepEqual(lookupRemoteMcp(claims.jti, "composio-user"), { url: "https://composio.example/mcp", headers: { "x-consumer-api-key": "endpoint-secret" } }); +}); + +test("an isolated run drops (and reports) a remote whose URL the relay cannot dial, never falling back to the token", async () => { + const { buildMcpRuntimePayload } = await import("../src/gateway/mcp.js"); + const before = process.env.TOOLBOX_MCP_URL; + process.env.TOOLBOX_MCP_URL = "http://toolbox.internal/mcp"; + try { + const payload = await buildMcpRuntimePayload(withIdentity({ toolboxToken: "tb-plain-http", composioToken: "ck_ok", target: createFakeRuntime().target() })); + const servers = JSON.parse(payload.configJson).mcpServers; + assert.equal(servers["makeitfuture-toolbox"], undefined); + assert.equal(servers["composio-agent"].env.CG_MCP_SERVICE, "remote-mcp"); + assert.doesNotMatch(payload.configJson, /tb-plain-http|toolbox\.internal/); + assert.deepEqual(payload.rejectedRemotes.map((r) => r.name), ["makeitfuture-toolbox"]); + assert.deepEqual(payload.relayedMcps, ["composio-agent"]); + // The host keeps today's behaviour for the same URL: the engine dials it itself. + const host = JSON.parse(await buildMcpConfig({ toolboxToken: "tb-plain-http" })); + assert.equal(host.mcpServers["makeitfuture-toolbox"].url, "http://toolbox.internal/mcp"); + } finally { + if (before === undefined) delete process.env.TOOLBOX_MCP_URL; else process.env.TOOLBOX_MCP_URL = before; + } +}); + +test("a host target keeps today's direct http entries, byte for byte, and registers nothing", async () => { + const { remoteMcpRegistryStats } = await import("../src/mcp/remote-mcp-registry.js"); + const { toolboxUrl } = await import("../src/gateway/mcp-catalog.js"); + const opts = { composioUserToken: "ck_user", toolboxToken: "tb-1", composioToken: "ck_shared", makeToolboxUrl: "https://eu1.make.com/mcp/server/x", makeToolboxKey: "mk" }; + const before = remoteMcpRegistryStats().registrations; + const host = JSON.parse(await buildMcpConfig(opts)); + const local = JSON.parse(await buildMcpConfig({ ...opts, target: localRuntimeTarget("/work") })); + assert.equal(remoteMcpRegistryStats().registrations, before, "a host run never touches the relay registry"); + for (const config of [host, local]) { + assert.equal(JSON.stringify(config.mcpServers["composio-user"]), JSON.stringify({ type: "http", url: "https://connect.composio.dev/mcp", headers: { "x-consumer-api-key": "ck_user" }, default_tools_approval_mode: "approve" })); + assert.equal(JSON.stringify(config.mcpServers["composio-agent"]), JSON.stringify({ type: "http", url: "https://connect.composio.dev/mcp", headers: { "x-consumer-api-key": "ck_shared" }, default_tools_approval_mode: "approve" })); + assert.equal(JSON.stringify(config.mcpServers["makeitfuture-toolbox"]), JSON.stringify({ type: "http", url: toolboxUrl(), headers: { Authorization: "Bearer tb-1" } })); + assert.equal(JSON.stringify(config.mcpServers["make-toolbox"]), JSON.stringify({ type: "http", url: "https://eu1.make.com/mcp/server/x", headers: { Authorization: "Bearer mk" } })); + assert.deepEqual(Object.keys(config.mcpServers), ["gateway", "composio-user", "composio-agent", "makeitfuture-toolbox", "make-toolbox"]); + const claims = verifyGatewayCapability(config.mcpServers.gateway.env.CG_GATEWAY_CAPABILITY, { secret: process.env.CG_APPROVAL_SECRET }).claims; + assert.equal(claims.remoteMcps, undefined, "a host capability carries no relay claim"); + } +}); test("untrusted run MCP config omits personal endpoints and cannot grant their URLs", async () => { const personalUrl = "https://app.composio.dev/tool_router/v3/trs_personal/mcp"; diff --git a/test/progress-report-availability.test.js b/test/progress-report-availability.test.js index b68cc1ad..f85c39dd 100644 --- a/test/progress-report-availability.test.js +++ b/test/progress-report-availability.test.js @@ -19,7 +19,7 @@ test("runMessage defaults progress report off and threads the opt-in through bot assert.match(run, /const mcpRuntimeInput = \{[\s\S]*?progressReport: progressReportEnabled/); // `target` rides alongside `engine`: mcp.js needs both to pick the gateway server entry (the // checkout's stdio server on the host, the in-container bridge for an isolated runtime). - assert.match(engineMcp, /buildMcpConfig\(\{ \.\.\.identity, engine, target \}\)/); + assert.match(engineMcp, /buildMcpRuntimePayload\(\{ \.\.\.identity, engine, target \}\)/); assert.match(run, /progressReport: progressReportEnabled,/); assert.ok((run.match(/progressReport: progressReportEnabled/g) || []).length >= 2); assert.match(adapters, /progressReport: r\.progressReport/); @@ -104,7 +104,7 @@ test("runMessage threads one per-channel Make toolbox through Claude, Codex, and assert.match(run, /const mcpRuntimeInput = \{[\s\S]*?makeToolboxUrl, makeToolboxKey/); // `target` rides alongside `engine`: mcp.js needs both to pick the gateway server entry (the // checkout's stdio server on the host, the in-container bridge for an isolated runtime). - assert.match(engineMcp, /buildMcpConfig\(\{ \.\.\.identity, engine, target \}\)/); + assert.match(engineMcp, /buildMcpRuntimePayload\(\{ \.\.\.identity, engine, target \}\)/); assert.ok((run.match(/makeToolboxUrl, makeToolboxKey/g) || []).length >= 2); assert.match(adapters, /makeToolboxUrl: r\.makeToolboxUrl, makeToolboxKey: r\.makeToolboxKey/); }); diff --git a/test/run-engine-mcp.test.js b/test/run-engine-mcp.test.js index dafcea69..8f2a2608 100644 --- a/test/run-engine-mcp.test.js +++ b/test/run-engine-mcp.test.js @@ -111,3 +111,28 @@ test("selected Claude MCP definitions reach the isolated payload and warm finger await fs.rm(temp, { recursive: true, force: true }); } }); + +// Container-secrets P1: on an isolated target a relayed remote's token is no longer in the JSON +// (the daemon's relay registry holds it), yet a rotated token must still retire the warm process. +test("a rotated relayed header changes the warm fingerprint; the same headers keep it", async () => { + const { createFakeRuntime } = await import("./fixtures/fake-runtime-backend.js"); + const target = createFakeRuntime().target(); + const input = { + channelId: "C_RELAY_FP", slug: "relay-fp", authorId: "U_RELAY_FP", threadKey: "9.1", origin: "slack_foreground", + principalTrusted: true, engine: "claude", fingerprintNow: 10_000, target, + composioUserToken: "ck_user_v1", composioToken: "ck_shared_v1", toolboxToken: "tb_v1", + makeToolboxUrl: "https://eu1.make.com/mcp/server/fp", makeToolboxKey: "mk_v1", + }; + const first = await buildEngineMcpRuntime(input); + const same = await buildEngineMcpRuntime(input); + assert.notEqual(first.gatewayCapability, same.gatewayCapability, "a fresh grant (and jti) per run"); + assert.equal(first.mcpConfigFingerprint, same.mcpConfigFingerprint, "same headers, same warm process"); + for (const secret of ["ck_user_v1", "ck_shared_v1", "tb_v1", "mk_v1"]) { + assert.ok(!first.mcpConfigJson.includes(secret), `${secret} never in the container's config`); + assert.ok(!first.mcpConfigFingerprint.includes(secret), `${secret} never in the fingerprint either`); + } + for (const [key, value] of [["composioUserToken", "ck_user_v2"], ["composioToken", "ck_shared_v2"], ["toolboxToken", "tb_v2"], ["makeToolboxKey", "mk_v2"], ["makeToolboxUrl", "https://eu2.make.com/mcp/server/fp"]]) { + const rotated = await buildEngineMcpRuntime({ ...input, [key]: value }); + assert.notEqual(rotated.mcpConfigFingerprint, first.mcpConfigFingerprint, `${key} rotation retires the warm process`); + } +}); From 806bf961ce0947d9c523af44963bffe96d41168b Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Sat, 26 Sep 2026 18:08:40 +0300 Subject: [PATCH 04/33] feat: relay Codex's remote MCPs in containers; bundle holds only the capability In a container, Codex's composio-user/-agent (token and endpoint mode), makeitfuture-toolbox and make-toolbox entries become the same secret-env-bridge -> socket-bridge chain as the gateway entry, selecting the `remote-mcp` service and naming the server. The per-run 0600 bundle then carries only `gatewayCapability` and no headers helper is written, so nothing in the container's artifact dir holds a remote MCP credential. The secret-env-bridge forwards CG_MCP_SERVICE and CG_MCP_SOCKET to the bridge it launches. Sudo-host turns keep the native transport with headers helpers. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Tiberiu Socaci --- src/engines/codex.js | 61 +++++++++++++++---- src/mcp/secret-env-bridge.js | 5 +- test/codex-args.test.js | 91 +++++++++++++++++++++------- test/engine-runtime-isolated.test.js | 40 +++++++++--- test/mcp-socket-server.test.js | 30 +++++++++ test/secret-env-bridge.test.js | 14 +++++ 6 files changed, 195 insertions(+), 46 deletions(-) diff --git a/src/engines/codex.js b/src/engines/codex.js index da12e942..4a9c19ff 100644 --- a/src/engines/codex.js +++ b/src/engines/codex.js @@ -11,10 +11,10 @@ // config overrides, so the same flags work on fresh and resumed runs — see buildCodexArgs. // - MCP injected with `-c mcp_servers.*` overrides — identical on a fresh run and on a resume, // because `exec resume` reads them the same way. Header-bearing remote MCPs (Composio, the -// toolboxes) use Codex's native streamable-HTTP transport with a per-run `http_headers_helper` -// script, NOT a stdio bridge: Codex takes whichever MCP servers finished starting by the time -// it builds the first request, and a resumed turn reaches that point far sooner than a fresh -// one (see addSecretRemote). +// toolboxes): in a CONTAINER they are relayed by the daemon over the control socket (the +// `remote-mcp` service), so the container holds no credential at all; on a sudo-host turn they +// use Codex's native streamable-HTTP transport with a per-run `http_headers_helper` script +// (see addSecretRemote). // - JSONL events (`--json`): thread.started carries the session id; the authoritative final // message is read from the `-o` file. Token usage comes from turn.completed; no dollar cost. // - timeoutMs is an inactivity watchdog, not a wall-clock runtime cap: a busy Codex turn may run @@ -41,6 +41,7 @@ import { createCodexUsageReader, subtractCodexTokenUsage } from "./codex-usage.j import { MCP_STARTUP_TIMEOUT_SECONDS } from "./mcp-timeouts.js"; import { gatewayRoot, runTmpDir } from "../config/paths.js"; import { readCodexAuthState, describeCodexAuth } from "./codex-auth.js"; +import { isRelayableUrl } from "../mcp/remote-mcp-registry.js"; const IMAGE_RE = /\.(png|jpe?g|gif|webp|bmp|heic|heif)$/i; const MAX_RETAINED = 64_000; // stdout/stderr/delta kept for error context — tail only, never unbounded @@ -58,9 +59,9 @@ const NOTE_INTERVAL_MS = 15_000; // floor between stderr diagnostics shown to th // One requirement this places on an isolated backend's helper table, because Codex reaches that // helper THROUGH the secret bridge rather than launching it itself: "gateway-mcp" must be node + // a script path the bridge will accept (it validates a .js suffix), not a bare wrapper executable. -// The image's "mcp-remote" helper is no longer part of a Codex run — remote MCPs are dialled by -// Codex itself now (see addSecretRemote) — but it stays in the table for the image contract and -// for any backend that still bridges one. +// The image's "mcp-remote" helper is no longer part of a Codex run — remote MCPs are relayed by the +// daemon in a container and dialled by Codex itself on the host (see addSecretRemote) — but it +// stays in the table for the image contract and for any backend that still bridges one. function helperScriptArgv(helper) { return helper.args?.length ? [...helper.args] : [helper.command]; } @@ -100,6 +101,15 @@ if (typeof value !== "string" || !value) { process.stdout.write(JSON.stringify({ [HEADER]: PREFIX + value })); `; } +// What the run's 0600 bundle holds. In a container it is ONLY the signed capability: the remote +// MCP credentials are relayed by the daemon (see addSecretRemote), and the artifact dir the bundle +// lives in is readable by every process in the container. A sudo-host turn keeps the tokens its +// headers helpers read. +export function codexSecretBundle({ isolated = false, gatewayCapability = "", composioUserToken = "", composioToken = "", toolboxToken = "", makeToolboxKey = "" } = {}) { + if (isolated) return { gatewayCapability }; + return { gatewayCapability, composioUserToken, composioToken, toolboxToken, makeToolboxKey }; +} + // Names that can ride in a `-c` dotted key path. TOML bare keys are exactly [A-Za-z0-9_-]; anything // else needs quoting, and quoting a `-c` segment does not mean what it means in a TOML file. const BARE_TOML_KEY = /^[A-Za-z0-9_-]{1,120}$/; @@ -742,10 +752,21 @@ export function buildCodexArgs({ prompt, sessionId, isNewSession, cwd, dangerous args.push("-c", `mcp_servers.gateway.startup_timeout_sec=60`); } - // A header-bearing remote MCP (both Composio identities, the toolboxes) is reached over Codex's - // OWN streamable-HTTP transport, and the header value is produced by a per-run helper script - // (`http_headers_helper`) that reads the 0600 bundle — so the credential is still absent from - // Codex's argv and environment. It used to be bridged to stdio through `mcp-remote` instead, + // A header-bearing remote MCP (both Composio identities, the toolboxes). + // + // ISOLATED (the channel container): relayed by the DAEMON over the control socket. The entry is + // the same secret-env-bridge → socket-bridge chain the gateway entry uses — the signed capability + // comes out of the 0600 bundle, never argv/env — selecting the `remote-mcp` service and naming + // the server; the daemon verifies the capability's `remoteMcps` claim plus its own registration + // (made when the capability was minted, src/gateway/mcp.js) and dials the real URL with the real + // header. The bundle then holds ONLY the capability and no headers helper is written, so no + // process in the container can read a Composio or toolbox credential. A URL the relay cannot dial + // (a non-https override) is skipped here exactly as mcp.js drops and reports it. + // + // HOST (a sudo-host turn): reached over Codex's OWN streamable-HTTP transport, and the header + // value is produced by a per-run helper script (`http_headers_helper`) that reads the 0600 bundle + // — so the credential is still absent from Codex's argv and environment. It used to be bridged to + // stdio through `mcp-remote` instead, // which cost ~2.4s to answer `tools/list` (two Node bootstraps, mcp-remote's OAuth discovery // round trip, then a duplicated initialize). Codex does not BLOCK a turn on MCP startup — it // takes whichever servers finished by the time it builds the first request, and @@ -754,8 +775,21 @@ export function buildCodexArgs({ prompt, sessionId, isNewSession, cwd, dangerous // bridged servers made the cold window and missed every warm one: the second turn of a Codex // thread saw `gateway` and no `composio-user`/`composio-agent` at all. The native transport // answers in ~1.2s, inside both. + const addRelayedRemote = (name) => { + const bridge = helper("secret-env-bridge"); + args.push("-c", `mcp_servers.${name}.command=${JSON.stringify(bridge.command)}`); + args.push("-c", `mcp_servers.${name}.args=${JSON.stringify([...(bridge.args || []), secretBundlePath, "gatewayCapability", "CG_GATEWAY_CAPABILITY", ...helperScriptArgv(helper("gateway-mcp")), name])}`); + args.push("-c", `mcp_servers.${name}.env.CG_MCP_SERVICE="remote-mcp"`); + args.push("-c", `mcp_servers.${name}.env.CG_ENGINE="codex"`); + args.push("-c", `mcp_servers.${name}.default_tools_approval_mode="approve"`); + args.push("-c", `mcp_servers.${name}.startup_timeout_sec=${MCP_STARTUP_TIMEOUT_SECONDS}`); + }; const addSecretRemote = (name, url, secretName, headerName, prefix = "") => { if (clean || !secretBundlePath || !secretName || !url) return; + if (isolated) { + if (isRelayableUrl(url)) addRelayedRemote(name); + return; + } const helperPath = headerHelperPath(secretBundlePath, name); args.push("-c", `mcp_servers.${name}.url=${JSON.stringify(url)}`); args.push("-c", `mcp_servers.${name}.http_headers_helper=${JSON.stringify(helperPath)}`); @@ -944,12 +978,13 @@ export async function runCodex({ const scratchDir = await mkdtemp(path.join(scratchBase, "run-")); const outFile = path.join(scratchDir, `cg-codex-${randomUUID()}.txt`); const secretDir = artifactDir ? path.join(artifactDir, "run") : runTmpDir(); - const secretBundlePath = !clean && [gatewayCapability, composioUserToken, composioToken, toolboxToken, makeToolboxKey].some(Boolean) + const bundle = codexSecretBundle({ isolated, gatewayCapability, composioUserToken, composioToken, toolboxToken, makeToolboxKey }); + const secretBundlePath = !clean && Object.values(bundle).some(Boolean) ? path.join(secretDir, `cg-codex-secrets-${randomUUID()}.json`) : ""; if (secretBundlePath) { await mkdir(secretDir, { recursive: true, mode: 0o700 }); - await writeFile(secretBundlePath, JSON.stringify({ gatewayCapability, composioUserToken, composioToken, toolboxToken, makeToolboxKey }), { mode: 0o600 }); + await writeFile(secretBundlePath, JSON.stringify(bundle), { mode: 0o600 }); } // The `http_headers_helper` scripts the argv names: written here because buildCodexArgs stays a // pure argv builder. They carry no credential of their own — each one reads its entry out of the diff --git a/src/mcp/secret-env-bridge.js b/src/mcp/secret-env-bridge.js index 675a2e99..5261ec93 100644 --- a/src/mcp/secret-env-bridge.js +++ b/src/mcp/secret-env-bridge.js @@ -29,7 +29,10 @@ if (typeof secret !== "string" || !secret) { process.exit(2); } -const contextNames = ["CG_ENGINE", "CG_FS_ROOT", "CG_WORKSPACE_DIR", "CHANNELGATE_DIR", "CLAUDE_GATEWAY_DIR", "PATH", "CG_PROGRESS_REPORT"]; +// CG_MCP_SERVICE / CG_MCP_SOCKET select the daemon socket service and path for the image's socket +// bridge (src/mcp/socket-server.js): Codex reaches the relayed remote MCPs ("remote-mcp") through +// this same broker, so the capability still comes from the bundle and never rides argv/env. +const contextNames = ["CG_ENGINE", "CG_FS_ROOT", "CG_WORKSPACE_DIR", "CHANNELGATE_DIR", "CLAUDE_GATEWAY_DIR", "PATH", "CG_PROGRESS_REPORT", "CG_MCP_SERVICE", "CG_MCP_SOCKET"]; const context = Object.fromEntries(contextNames.filter((name) => typeof process.env[name] === "string").map((name) => [name, process.env[name]])); const child = spawn(process.execPath, [scriptPath, ...scriptArgs], { env: buildChildEnv({ ...context, [envName]: secret }), diff --git a/test/codex-args.test.js b/test/codex-args.test.js index b04729f7..8bd17514 100644 --- a/test/codex-args.test.js +++ b/test/codex-args.test.js @@ -12,7 +12,7 @@ import { ensureTestEnv, tempDir } from "./helpers.js"; import { createFakeRuntime } from "./fixtures/fake-runtime-backend.js"; const scratch = ensureTestEnv(); -const { buildCodexArgs, buildCodexEnv, createCodexProgressState, headerHelperPath, headerHelperSource, progressFromCodexEvent } = await import("../src/engines/codex.js"); +const { buildCodexArgs, buildCodexEnv, codexSecretBundle, createCodexProgressState, headerHelperPath, headerHelperSource, progressFromCodexEvent } = await import("../src/engines/codex.js"); const { CONTAINER_HOME, CONTAINER_PATH } = await import("../src/engines/runtime-target.js"); const { hostBackend } = await import("../src/runtimes/host.js"); const { workspaceRoot } = await import("../src/config/paths.js"); @@ -321,7 +321,11 @@ test("Codex gateway MCP exposes progress report only for opted-in foreground run assert.ok(!defaulted.some((arg) => arg.startsWith("mcp_servers.gateway.env.CG_PROGRESS_REPORT="))); }); -test("Codex runs inject personal and shared Composio MCPs outside clean mode", () => { +// Container-secrets P1: in a container both Composio identities are relayed by the DAEMON over +// the control socket. Codex launches the image's socket bridge through the secret-env-bridge (the +// capability comes out of the bundle), selecting the `remote-mcp` service and naming the server — +// no URL, no header, no headers helper crosses into the container. +test("Codex runs relay personal and shared Composio MCPs through the daemon outside clean mode", () => { const userToken = "ck_user_secret"; const sharedToken = "ck_shared_secret"; const bundle = `${target.artifactDir}/run/codex-secrets.json`; @@ -329,30 +333,50 @@ test("Codex runs inject personal and shared Composio MCPs outside clean mode", ( const args = argsFor({ composioUserToken: userToken, composioToken: sharedToken, secretBundlePath: bundle, headerHelpers }); const joined = args.join("\n"); - // Codex dials both identities itself over its native streamable-HTTP transport. The old - // `mcp-remote` stdio bridge cost ~2.4s to answer tools/list and lost the race against a resumed - // turn's much shorter MCP startup window — the warm turn saw no Composio family at all. + for (const name of ["composio-user", "composio-agent"]) { + assert.ok(args.includes(`mcp_servers.${name}.command="/usr/local/bin/node"`), name); + assert.ok(args.includes(`mcp_servers.${name}.args=${JSON.stringify(["/opt/channelgate/mcp/secret-env-bridge.js", bundle, "gatewayCapability", "CG_GATEWAY_CAPABILITY", "/opt/channelgate/bin/cg-mcp-bridge.js", name])}`), name); + assert.ok(args.includes(`mcp_servers.${name}.env.CG_MCP_SERVICE="remote-mcp"`), name); + assert.ok(args.includes(`mcp_servers.${name}.env.CG_ENGINE="codex"`), name); + assert.ok(args.includes(`mcp_servers.${name}.default_tools_approval_mode="approve"`), name); + assert.ok(args.includes(`mcp_servers.${name}.startup_timeout_sec=120`), name); + assert.ok(!args.some((arg) => arg.startsWith(`mcp_servers.${name}.url=`)), `${name}: the container never dials the remote itself`); + assert.ok(!args.some((arg) => arg.startsWith(`mcp_servers.${name}.http_headers_helper=`)), `${name}: no headers helper in a container`); + } + assert.deepEqual(headerHelpers, [], "no helper script is written for an isolated run"); + assert.doesNotMatch(joined, /remote-secret-bridge\.js|mcp-remote|connect\.composio\.dev|x-consumer-api-key/); + assert.ok(!args.some((arg) => arg.startsWith("mcp_servers.composio.")), "the bare legacy name is never emitted"); + assert.doesNotMatch(joined, new RegExp(`${userToken}|${sharedToken}`)); + + const cleanArgs = argsFor({ clean: true, composioUserToken: userToken, composioToken: sharedToken }); + assert.ok(!cleanArgs.some((arg) => arg.startsWith("mcp_servers.composio-user."))); + assert.ok(!cleanArgs.some((arg) => arg.startsWith("mcp_servers.composio-agent."))); +}); + +// A sudo-host turn keeps today's shape: Codex dials both identities itself over its native +// streamable-HTTP transport. The old `mcp-remote` stdio bridge cost ~2.4s to answer tools/list and +// lost the race against a resumed turn's much shorter MCP startup window. +test("sudo-host Codex dials Composio itself, with the credential from a per-run headers helper", () => { + const host = hostBackend.prepareTarget({ slug: "sudo", cwd: "/work", workDir: "/work", cleanWorkDir: "", meta: { sudoMode: true }, settings: {} }); + const userToken = "ck_user_secret"; + const sharedToken = "ck_shared_secret"; + const bundle = "/tmp/run/codex-secrets.json"; + const headerHelpers = []; + const args = argsFor({ target: host, cwd: "/work", outFile: "/tmp/out.txt", composioUserToken: userToken, composioToken: sharedToken, secretBundlePath: bundle, headerHelpers }); + const joined = args.join("\n"); assert.ok(args.includes(`mcp_servers.composio-user.url="https://connect.composio.dev/mcp"`)); assert.ok(args.includes(`mcp_servers.composio-agent.url="https://connect.composio.dev/mcp"`)); - assert.doesNotMatch(joined, /remote-secret-bridge\.js|mcp-remote/, "no stdio bridge stands between Codex and a remote MCP any more"); + assert.doesNotMatch(joined, /remote-secret-bridge\.js|mcp-remote/, "no stdio bridge stands between Codex and a remote MCP"); assert.ok(!args.some((arg) => /^mcp_servers\.composio-(user|agent)\.command=/.test(arg)), "an http server has no command"); - - // The credential still comes from the 0600 bundle, resolved by the run's own headers helper. assert.ok(args.includes(`mcp_servers.composio-user.http_headers_helper=${JSON.stringify(headerHelperPath(bundle, "composio-user"))}`)); assert.ok(args.includes(`mcp_servers.composio-agent.http_headers_helper=${JSON.stringify(headerHelperPath(bundle, "composio-agent"))}`)); assert.ok(args.includes(`mcp_servers.composio-user.default_tools_approval_mode="approve"`)); - assert.ok(args.includes(`mcp_servers.composio-agent.default_tools_approval_mode="approve"`)); - assert.ok(!args.some((arg) => arg.startsWith("mcp_servers.composio.")), "the bare legacy name is never emitted"); + assert.ok(!args.some((arg) => arg.startsWith("mcp_servers.composio-user.env.CG_MCP_SERVICE=")), "the relay is a container-only path"); assert.doesNotMatch(joined, new RegExp(`${userToken}|${sharedToken}`)); - assert.deepEqual(headerHelpers.map((spec) => [spec.secretName, spec.headerName, spec.prefix]), [ ["composioUserToken", "x-consumer-api-key", ""], ["composioToken", "x-consumer-api-key", ""], ]); - - const cleanArgs = argsFor({ clean: true, composioUserToken: userToken, composioToken: sharedToken }); - assert.ok(!cleanArgs.some((arg) => arg.startsWith("mcp_servers.composio-user."))); - assert.ok(!cleanArgs.some((arg) => arg.startsWith("mcp_servers.composio-agent."))); }); // WB-10. `codex exec resume` honours `-c mcp_servers.*` exactly like a fresh `exec`, so the two @@ -431,22 +455,34 @@ test("Codex bridges SDK sessions without putting the organization key on argv", assert.doesNotMatch(joined, /sdk-super-secret|x-api-key/i); }); -test("Codex injects a Make toolbox through a bearer environment variable hidden from shell commands", () => { +test("Codex reaches a Make toolbox through the daemon relay, with no key in argv, env or a helper", () => { const key = "make-secret-key"; + const bundle = `${target.artifactDir}/run/codex-secrets.json`; + const headerHelpers = []; const args = argsFor({ makeToolboxUrl: "https://eu1.make.celonis.com/mcp/server/abc-123", makeToolboxKey: key, - secretBundlePath: `${target.artifactDir}/run/codex-secrets.json`, + secretBundlePath: bundle, + headerHelpers, }); const joined = args.join("\n"); const env = buildCodexEnv({ target }, {}); - assert.ok(args.includes(`mcp_servers.make-toolbox.url="https://eu1.make.celonis.com/mcp/server/abc-123"`)); - assert.match(joined, /make-toolbox\.headers\.cjs/); + assert.ok(args.includes(`mcp_servers.make-toolbox.args=${JSON.stringify(["/opt/channelgate/mcp/secret-env-bridge.js", bundle, "gatewayCapability", "CG_GATEWAY_CAPABILITY", "/opt/channelgate/bin/cg-mcp-bridge.js", "make-toolbox"])}`)); + assert.ok(args.includes(`mcp_servers.make-toolbox.env.CG_MCP_SERVICE="remote-mcp"`)); assert.ok(args.includes(`mcp_servers.make-toolbox.default_tools_approval_mode="approve"`)); + assert.ok(!args.some((arg) => arg.startsWith("mcp_servers.make-toolbox.url=")), "the URL stays with the daemon's registration"); + assert.doesNotMatch(joined, /headers\.cjs|make\.celonis\.com/); + assert.deepEqual(headerHelpers, []); assert.equal(env.CG_MAKE_TOOLBOX_KEY, undefined); assert.doesNotMatch(joined, new RegExp(key)); + // The sudo host keeps the native transport with a headers helper. + const host = hostBackend.prepareTarget({ slug: "sudo", cwd: "/work", workDir: "/work", cleanWorkDir: "", meta: { sudoMode: true }, settings: {} }); + const hostArgs = argsFor({ target: host, cwd: "/work", outFile: "/tmp/out.txt", makeToolboxUrl: "https://eu1.make.celonis.com/mcp/server/abc-123", makeToolboxKey: key, secretBundlePath: "/tmp/b.json" }); + assert.ok(hostArgs.includes(`mcp_servers.make-toolbox.url="https://eu1.make.celonis.com/mcp/server/abc-123"`)); + assert.match(hostArgs.join("\n"), /make-toolbox\.headers\.cjs/); + const incomplete = argsFor({ makeToolboxUrl: "https://eu2.make.com/mcp/server/abc" }); const clean = argsFor({ clean: true, @@ -486,9 +522,20 @@ test("every connector secret stays out of Codex argv and child env", () => { assert.equal(env.OPENAI_API_KEY, "engine-auth-only"); assert.match(argv, /secret-env-bridge\.js/); assert.match(argv, /gatewayCapability/); - // Every remote MCP names a headers helper instead of a header value. - assert.match(argv, /composio-user\.headers\.cjs/); - assert.match(argv, /makeitfuture-toolbox\.headers\.cjs/); + // Every remote MCP is relayed by the daemon: no headers helper, no URL, only the server's name. + assert.doesNotMatch(argv, /headers\.cjs/); + for (const name of ["composio-user", "composio-agent", "makeitfuture-toolbox", "make-toolbox"]) { + assert.ok(args.includes(`mcp_servers.${name}.env.CG_MCP_SERVICE="remote-mcp"`), name); + } + // …and the run's bundle — a file in the artifact dir every container process can read — holds + // the signed capability and NOTHING else. The sudo host keeps the tokens its helpers read. + const isolatedBundle = codexSecretBundle({ isolated: true, ...secrets }); + assert.deepEqual(isolatedBundle, { gatewayCapability: secrets.gatewayCapability }); + const serialized = JSON.stringify(isolatedBundle); + for (const [name, secret] of Object.entries(secrets)) { + if (name !== "gatewayCapability") assert.ok(!serialized.includes(secret), `${name} never in the isolated bundle`); + } + assert.deepEqual(codexSecretBundle({ isolated: false, ...secrets }), secrets); }); test("full-access Codex runs keep the deliberate bypass and no sandbox mode", () => { diff --git a/test/engine-runtime-isolated.test.js b/test/engine-runtime-isolated.test.js index 24613650..3b3da6b1 100644 --- a/test/engine-runtime-isolated.test.js +++ b/test/engine-runtime-isolated.test.js @@ -7,7 +7,7 @@ // non-isolated path is used by the explicitly admitted sudo-host runtime. import test from "node:test"; import assert from "node:assert/strict"; -import { readdirSync, rmSync, writeFileSync } from "node:fs"; +import { readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { ensureTestEnv, tempDir } from "./helpers.js"; import { createFakeRuntime } from "./fixtures/fake-runtime-backend.js"; @@ -241,15 +241,21 @@ test("Codex MCP entries in a container are composed from the runtime's helper co assert.equal(cfg(args, `mcp_servers.gateway.env.${key}=`), "", `${key} must not reach a container`); } - // A header-bearing remote MCP is Codex's own HTTP client plus a per-run headers helper beside the - // bundle — no image bridge, because a stdio bridge could not start fast enough to be in the tool - // registry of a RESUMED turn. The Composio SDK bridge is still the image's. - assert.equal(cfg(args, "mcp_servers.composio-user.command="), "", "an http server has no command"); - assert.equal(cfg(args, "mcp_servers.composio-user.url="), `mcp_servers.composio-user.url="https://composio.example/mcp"`); - assert.equal( - cfg(args, "mcp_servers.composio-user.http_headers_helper="), - `mcp_servers.composio-user.http_headers_helper="${target.artifactDir}/run/bundle-composio-user.headers.cjs"`, - ); + // A header-bearing remote MCP is relayed by the daemon (container-secrets P1): the same + // secret-env-bridge → socket-bridge chain as the gateway entry, naming the server, with no URL and + // no headers helper in the container. The Composio SDK bridge is still the image's. + assert.equal(cfg(args, "mcp_servers.composio-user.command="), `mcp_servers.composio-user.command="/usr/local/bin/node"`); + assert.deepEqual(cfgJson(args, "mcp_servers.composio-user.args="), [ + "/opt/channelgate/mcp/secret-env-bridge.js", + bundle, + "gatewayCapability", + "CG_GATEWAY_CAPABILITY", + "/opt/channelgate/bin/cg-mcp-bridge.js", + "composio-user", + ]); + assert.equal(cfg(args, "mcp_servers.composio-user.env.CG_MCP_SERVICE="), `mcp_servers.composio-user.env.CG_MCP_SERVICE="remote-mcp"`); + assert.equal(cfg(args, "mcp_servers.composio-user.url="), "", "the container never dials the remote itself"); + assert.equal(cfg(args, "mcp_servers.composio-user.http_headers_helper="), "", "no headers helper in a container"); assert.deepEqual(cfgJson(args, "mcp_servers.composio-agent.args="), [ "/opt/channelgate/mcp/composio-sdk-bridge.js", "https://backend.composio.dev/api/v3/tool_router/x/mcp", @@ -290,6 +296,11 @@ test("a containerized Codex turn writes its answer file and secret bundle into t isNewSession: true, writable: true, gatewayCapability: "signed-cap", + composioUserToken: "ck-user-in-bundle?", + composioToken: "ck-shared-in-bundle?", + toolboxToken: "tb-in-bundle?", + makeToolboxUrl: "https://eu1.make.com/mcp/server/b", + makeToolboxKey: "mk-in-bundle?", target, artifactDir, timeoutMs: 60_000, @@ -301,6 +312,15 @@ test("a containerized Codex turn writes its answer file and secret bundle into t assert.ok(outFile.startsWith(path.join(artifactDir, "tmp")), `-o must live in the mounted artifact dir, got ${outFile}`); const bundle = cfgJson(spec.args, "mcp_servers.gateway.args=")[1]; assert.ok(bundle.startsWith(path.join(artifactDir, "run")), `the secret bundle must be readable inside the container, got ${bundle}`); + // Container-secrets P1: every process in the container can read that dir, so the bundle holds + // the signed capability ONLY — the remote MCP tokens are relayed by the daemon — and no headers + // helper is written beside it. + assert.deepEqual(JSON.parse(readFileSync(bundle, "utf8")), { gatewayCapability: "signed-cap" }); + assert.deepEqual(readdirSync(path.dirname(bundle)).filter((name) => name.endsWith(".cjs")), []); + for (const name of ["composio-user", "composio-agent", "makeitfuture-toolbox", "make-toolbox"]) { + assert.equal(cfg(spec.args, `mcp_servers.${name}.env.CG_MCP_SERVICE=`), `mcp_servers.${name}.env.CG_MCP_SERVICE="remote-mcp"`, name); + } + assert.ok(!spec.args.some((arg) => /in-bundle/.test(arg)), "no token in argv either"); assert.ok(!spec.args.some((arg) => arg.startsWith(gatewayRoot())), "nothing under the daemon root is named to the engine"); assert.equal(spec.env.HOME, CONTAINER_HOME); diff --git a/test/mcp-socket-server.test.js b/test/mcp-socket-server.test.js index 3f77990c..356822fe 100644 --- a/test/mcp-socket-server.test.js +++ b/test/mcp-socket-server.test.js @@ -390,3 +390,33 @@ test("a remote that cannot be dialled is refused with a fixed sentence that quot const reply = await rawExchange(socketPath, hello(capability({ remoteMcps: ["composio-user"], jti }), "composio-user")); assert.deepEqual(JSON.parse(reply.trim()), { channelgate: "error", reason: "remote MCP unavailable" }); }); + +// Codex's exact chain in a container: secret-env-bridge reads the capability out of the 0600 bundle +// and launches the socket bridge with CG_MCP_SERVICE=remote-mcp and the server name. +test("Codex's chain (secret-env-bridge → socket bridge → remote-mcp) relays with the capability from the bundle", async (t) => { + const remote = fakeRemote(); + const socketPath = await serverOn(t, {}, { connectRemote: remote.connectRemote }); + const jti = "relay-jti-codex-chain"; + registerRemoteMcps({ jti, exp: Date.now() + 60_000, servers: { "make-toolbox": RELAYED["make-toolbox"] } }); + t.after(() => clearRemoteMcps(jti)); + const bundleDir = shortSocketDir(); + const bundle = path.join(bundleDir, "bundle.json"); + const { writeFileSync } = await import("node:fs"); + writeFileSync(bundle, JSON.stringify({ gatewayCapability: capability({ remoteMcps: ["make-toolbox"], jti }) }), { mode: 0o600 }); + const secretEnvBridge = fileURLToPath(new URL("../src/mcp/secret-env-bridge.js", import.meta.url)); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [secretEnvBridge, bundle, "gatewayCapability", "CG_GATEWAY_CAPABILITY", BRIDGE, "make-toolbox"], + stderr: "pipe", + env: { PATH: process.env.PATH || "", CG_MCP_SOCKET: socketPath, CG_MCP_SERVICE: "remote-mcp", CG_ENGINE: "codex" }, + }); + const client = new Client({ name: "codex-chain", version: "1.0.0" }, { capabilities: {} }); + try { + await client.connect(transport); + const result = await client.callTool({ name: "COMPOSIO_SEARCH_TOOLS", arguments: { q: "scenarios" } }); + assert.equal(result.content[0].text, "echo:scenarios"); + } finally { + await client.close().catch(() => {}); + } + assert.deepEqual(remote.dials, [{ url: RELAYED["make-toolbox"].url, headers: RELAYED["make-toolbox"].headers }]); +}); diff --git a/test/secret-env-bridge.test.js b/test/secret-env-bridge.test.js index 6691373d..d144ffc6 100644 --- a/test/secret-env-bridge.test.js +++ b/test/secret-env-bridge.test.js @@ -32,3 +32,17 @@ test("Codex secret bridge preserves daemon-canonical gateway path context", () = assert.equal(result.status, 0, result.stderr); assert.deepEqual(JSON.parse(result.stdout), { fsRoot, workspace }); }); + +test("the broker forwards the socket service selection and path to the bridge it launches", () => { + const scratch = tempDir("cg-secret-bridge-svc-"); + const bundle = path.join(scratch, "bundle.json"); + const probe = path.join(scratch, "probe.js"); + writeFileSync(bundle, JSON.stringify({ gatewayCapability: "signed-capability" }), { mode: 0o600 }); + writeFileSync(probe, "process.stdout.write(JSON.stringify({ service: process.env.CG_MCP_SERVICE, socket: process.env.CG_MCP_SOCKET, cap: process.env.CG_GATEWAY_CAPABILITY, argv: process.argv.slice(2), other: process.env.SOME_DAEMON_SECRET || '' }));\n"); + const result = spawnSync(process.execPath, [bridge, bundle, "gatewayCapability", "CG_GATEWAY_CAPABILITY", probe, "composio-user"], { + encoding: "utf8", + env: { PATH: process.env.PATH, CG_MCP_SERVICE: "remote-mcp", CG_MCP_SOCKET: "/run/channelgate/mcp.sock", SOME_DAEMON_SECRET: "never" }, + }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { service: "remote-mcp", socket: "/run/channelgate/mcp.sock", cap: "signed-capability", argv: ["composio-user"], other: "" }); +}); From 294b58cd0ec42b58208ff5b37b92ce02dd5d29b3 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Sat, 26 Sep 2026 18:09:19 +0300 Subject: [PATCH 05/33] feat: SSH sessions get relayed remote MCPs and a capability-only Codex bundle An SSH session's mcp.json already comes from the turn's assembler with the container target, so Composio and the toolboxes are now relayed there too. Its Codex bundle holds only `gatewayCapability` and no headers helper is written; the relay registrations ride the session capability's 12-hour lifetime and a refresh registers under a fresh jti. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Tiberiu Socaci --- src/gateway/ssh-session.js | 23 +++++++++++++----- test/ssh-session.test.js | 50 +++++++++++++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/gateway/ssh-session.js b/src/gateway/ssh-session.js index 15631fc1..ed8e831f 100644 --- a/src/gateway/ssh-session.js +++ b/src/gateway/ssh-session.js @@ -36,7 +36,8 @@ import { safeSpawnEnv } from "../config/channel-env.js"; import { requireAdapter } from "../engines/registry.js"; import { CONTAINER_CLAUDE_CONFIG_DIR } from "../runtimes/container/image-paths.js"; import { installVscodeClaudeRelay, installSshCodexWrapper, CLAUDE_ONBOARDING_FILE, sshUsersDirOf } from "../runtimes/container/vscode.js"; -import { buildCodexArgs, headerHelperSource } from "../engines/codex.js"; +import { buildCodexArgs, codexSecretBundle, headerHelperSource } from "../engines/codex.js"; +import { isIsolatedTarget } from "../engines/runtime-target.js"; import { listEngineMcps, codexMcpPolicyFor } from "./mcp-discovery.js"; import { readDaemonClaudeAccount } from "./claude-token-relay.js"; import { buildSettings } from "./folders.js"; @@ -161,10 +162,13 @@ fs.renameSync(temporary, file); fs.writeFileSync(sidecar, folder + "\\n", { mode: 0o600 }); `; -// Codex's half of a prepared session (codex-args.sh + its bundle and header helpers, all in the -// developer's 0700 dir): exactly the `-c mcp_servers.*` / `apps.*` overrides a chat turn's Codex -// gets, with the gateway capability and Composio/toolbox credentials in a 0600 bundle their helpers -// read — never in argv. The sandbox, approval and model flags of a turn are NOT carried: the +// Codex's half of a prepared session (codex-args.sh + its bundle, in the developer's 0700 dir): +// exactly the `-c mcp_servers.*` / `apps.*` overrides a chat turn's Codex gets, with the gateway +// capability in a 0600 bundle — never in argv. The Composio/toolbox servers are relayed by the +// daemon (the `remote-mcp` socket service) exactly as in a turn, so the bundle holds the capability +// and nothing else, no headers helper is written, and the relay registration made when the +// capability was minted lives as long as the capability (SSH_CAPABILITY_TTL_MS); a refresh mints a +// fresh jti and registers again, the old one simply expires. The sandbox, approval and model flags of a turn are NOT carried: the // developer drives an interactive Codex and answers its prompts themselves. export function renderCodexArgsScript(overrides) { const quote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`; @@ -197,7 +201,14 @@ async function prepareCodexSessionFiles({ target, userDir, integrations, meta, c const policy = codexMcpPolicyFor(await listMcps(), allowed); for (const server of policy.servers) if (server.enabled && !server.definition) server.enabled = false; const gatewayCapability = runtime.gatewayCapability || ""; - const bundle = { gatewayCapability, composioUserToken: integrations.composioUserToken || "", composioToken: integrations.composioToken || "", toolboxToken: integrations.toolboxToken || "", makeToolboxKey: integrations.makeToolboxKey || "" }; + const bundle = codexSecretBundle({ + isolated: isIsolatedTarget(target), + gatewayCapability, + composioUserToken: integrations.composioUserToken || "", + composioToken: integrations.composioToken || "", + toolboxToken: integrations.toolboxToken || "", + makeToolboxKey: integrations.makeToolboxKey || "", + }); const secretBundlePath = !clean && Object.values(bundle).some(Boolean) ? path.join(userDir, "codex-secrets.json") : ""; if (secretBundlePath) writePrivate(secretBundlePath, JSON.stringify(bundle)); else rmSync(path.join(userDir, "codex-secrets.json"), { force: true }); // Lean: no stale tokens diff --git a/test/ssh-session.test.js b/test/ssh-session.test.js index 0cfa2fa7..b8f48a18 100644 --- a/test/ssh-session.test.js +++ b/test/ssh-session.test.js @@ -7,7 +7,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import path from "node:path"; import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { ensureTestEnv } from "./helpers.js"; const scratch = ensureTestEnv(); @@ -84,8 +84,21 @@ test("the session is prepared like a turn: lockdown, MCP payload signed for THIS assert.equal(verified.claims.origin, session.SSH_SESSION_ORIGIN); assert.equal(verified.claims.toolset, SSH_TOOLSET); assert.equal(verified.claims.exp - verified.claims.iat, session.SSH_CAPABILITY_TTL_MS); - assert.ok(JSON.stringify(mcp["composio-user"]).includes(`cu-${user.id}`), "the developer's OWN Composio identity"); - assert.ok(JSON.stringify(mcp["composio-agent"]).includes("ca-channel"), "and the channel's"); + // Both identities are relayed by the daemon (container-secrets P1): mcp.json lives in the + // artifact dir every process in the container can read, so it names the servers and carries the + // capability only; the developer's own token and the channel's are in the daemon's relay + // registry, under THIS session capability's jti, for the session's 12-hour lifetime. + const { lookupRemoteMcp } = await import("../src/mcp/remote-mcp-registry.js"); + const mcpText = readFileSync(path.join(dir, "mcp.json"), "utf8"); + assert.ok(!mcpText.includes(`cu-${user.id}`) && !mcpText.includes("ca-channel"), "no Composio credential in the session's mcp.json"); + for (const name of ["composio-user", "composio-agent"]) { + assert.equal(mcp[name].env.CG_MCP_SERVICE, "remote-mcp", name); + assert.equal(mcp[name].args.at(-1), name); + assert.equal(mcp[name].env.CG_GATEWAY_CAPABILITY, mcp.gateway.env.CG_GATEWAY_CAPABILITY); + } + assert.deepEqual(verified.claims.remoteMcps, ["composio-user", "composio-agent"]); + assert.deepEqual(lookupRemoteMcp(verified.claims.jti, "composio-user", verified.claims.iat + 1).headers, { "x-consumer-api-key": `cu-${user.id}` }, "the developer's OWN Composio identity"); + assert.deepEqual(lookupRemoteMcp(verified.claims.jti, "composio-agent", verified.claims.iat + 1).headers, { "x-consumer-api-key": "ca-channel" }, "and the channel's"); // The env file: every value single-quoted so a POSIX shell reproduces it exactly; no token in it // when the account login was written (the file login is what Claude shows as the account). const envFile = readFileSync(path.join(dir, "env"), "utf8"); @@ -265,7 +278,7 @@ test("session prep seeds the VS Code start folder with the channel's effective w assert.ok(warnings.some((m) => m.includes("VS Code start folder not set"))); }); -test("Codex over SSH gets the turn's MCP servers from a 0600 bundle, never a credential in the overrides", async () => { +test("Codex over SSH gets the turn's MCP servers from a 0600 bundle, never a credential in the overrides or the bundle", async () => { // QA-0925: `codex` in an SSH session had no gateway MCP, no Composio and none of the secrets. const t = target("ssh-codex"); const { execs, deps } = fakes(); @@ -280,13 +293,24 @@ test("Codex over SSH gets the turn's MCP servers from a 0600 bundle, never a cre assert.doesNotMatch(script, /CG_GATEWAY_CAPABILITY=|eyJ/, "the capability rides the bundle, not the overrides"); const bundle = JSON.parse(readFileSync(path.join(dir, "codex-secrets.json"), "utf8")); assert.equal(statSync(path.join(dir, "codex-secrets.json")).mode & 0o777, 0o600); - assert.equal(bundle.composioUserToken, `cu-${user.id}`); - assert.equal(bundle.composioToken, "ca-channel"); + // The bundle holds the capability and NOTHING else: Composio is relayed by the daemon, so no + // token and no headers helper sits in the developer's dir (container-secrets P1). + assert.deepEqual(Object.keys(bundle), ["gatewayCapability"]); + const bundleText = readFileSync(path.join(dir, "codex-secrets.json"), "utf8"); + for (const secret of [`cu-${user.id}`, "ca-channel"]) assert.ok(!bundleText.includes(secret), `${secret} never in the bundle`); + assert.deepEqual(readdirSync(dir).filter((name) => name.endsWith(".headers.cjs")), [], "no headers helper is written"); + for (const name of ["composio-user", "composio-agent"]) { + assert.ok(script.includes(`mcp_servers.${name}.env.CG_MCP_SERVICE="remote-mcp"`), `${name} is relayed`); + } const verified = verifyGatewayCapability(bundle.gatewayCapability, { secret: SECRET }); assert.equal(verified.ok, true); assert.equal(verified.claims.engine, "codex", "minted for the engine that holds it"); assert.equal(verified.claims.authorId, user.id); assert.equal(verified.claims.toolset, SSH_TOOLSET); + assert.deepEqual(verified.claims.remoteMcps, ["composio-user", "composio-agent"], "the Codex capability is registered for its own relays"); + const { lookupRemoteMcp } = await import("../src/mcp/remote-mcp-registry.js"); + assert.deepEqual(lookupRemoteMcp(verified.claims.jti, "composio-user", verified.claims.iat + 1).headers, { "x-consumer-api-key": `cu-${user.id}` }); + assert.equal(verified.claims.exp - verified.claims.iat, session.SSH_CAPABILITY_TTL_MS, "the relay registration lives as long as the session's capability"); // Sourcing the script prepends the overrides and keeps the developer's own arguments last. const argv = execFileSync("sh", ["-c", `. '${argsFile}'; printf '%s\\n' "$@"`, "sh", "resume", "--last"], { encoding: "utf8" }).trim().split("\n"); assert.deepEqual(argv.slice(-2), ["resume", "--last"]); @@ -326,3 +350,17 @@ test("the codex wrapper sources the secrets, applies the overrides and starts in writeFileSync(helper, renderWithSecrets({ usersDir: users }), { mode: 0o755 }); assert.equal(execFileSync(helper, ["sh", "-c", "printf %s \"$MAKE_API_ADMIN\""], { encoding: "utf8", env: { PATH: process.env.PATH, CG_SSH_USER: "U1" } }), "mk"); }); + +test("re-preparing a session registers a fresh relay grant; the previous one lives out its own capability", async () => { + const { lookupRemoteMcp } = await import("../src/mcp/remote-mcp-registry.js"); + const t = target("ssh-relay-refresh"); + const jtis = []; + for (let i = 0; i < 2; i++) { + const { deps } = fakes(); + await session.prepareSshSession({ target: t, entry: { slug: "ssh-relay-refresh", channelId: "C_RR" }, meta: {}, user, cliBin: "podman", log: { warn() {} } }, deps); + const mcp = JSON.parse(readFileSync(path.join(session.sshUserDir(t, user.id), "mcp.json"), "utf8")).mcpServers; + jtis.push(verifyGatewayCapability(mcp.gateway.env.CG_GATEWAY_CAPABILITY, { secret: SECRET }).claims.jti); + } + assert.notEqual(jtis[0], jtis[1]); + for (const jti of jtis) assert.ok(lookupRemoteMcp(jti, "composio-agent"), "a still-running claude keeps the relay it started with"); +}); From 63d069b07595690f5662c9b8fc9497999ed06315 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Sat, 26 Sep 2026 18:09:33 +0300 Subject: [PATCH 06/33] chore: bump the image spec to 1.6.0 for the remote-mcp bridge contract The image's socket bridge and secret-env-bridge now carry the `remote-mcp` service; an older image's broker would drop the service selection, so the daemon must ask for a rebuild. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Tiberiu Socaci --- containers/versions.json | 2 +- src/runtimes/container/image-paths.js | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/containers/versions.json b/containers/versions.json index a1abe144..1fb74dd7 100644 --- a/containers/versions.json +++ b/containers/versions.json @@ -1,5 +1,5 @@ { - "imageSpecVersion": "1.5.1", + "imageSpecVersion": "1.6.0", "comment": "Pinned toolchain for the channel image. Keep `claude` and `codex` at the versions the gateway host runs, so a channel behaves identically on either runtime backend; bump deliberately, rebuild, and let the image-id fingerprint recreate the containers. `imageSpecVersion` is the image CONTRACT (paths, PATH, baked-in packages), not the pins: bump it when the image gains or moves something the daemon relies on, keep it equal to IMAGE_SPEC_VERSION in src/runtimes/container/image-paths.js (a test pins them together), and the daemon will tell an operator at boot when the built image is older.", "base": "node:22-bookworm-slim", "npm": { diff --git a/src/runtimes/container/image-paths.js b/src/runtimes/container/image-paths.js index c40d6655..319c4318 100644 --- a/src/runtimes/container/image-paths.js +++ b/src/runtimes/container/image-paths.js @@ -8,7 +8,7 @@ // pins the two together. The daemon COMPARES it at boot: an image built from an older spec still // runs, but the operator is told to rebuild rather than left wondering why a channel is missing // this build's toolchain. -export const IMAGE_SPEC_VERSION = "1.5.1"; +export const IMAGE_SPEC_VERSION = "1.6.0"; export const CONTAINER_HOME = "/home/agent"; // Where a channel's OWN installs land, in precedence order, ahead of the image's root-owned @@ -46,6 +46,11 @@ export const CONTAINER_SOCKET_FILE = "/run/channelgate/mcp.sock"; // through `secret-env-bridge`, which re-execs `process.execPath