diff --git a/KNOWN-LIMITATIONS.md b/KNOWN-LIMITATIONS.md index 647bf4f9..359e738c 100644 --- a/KNOWN-LIMITATIONS.md +++ b/KNOWN-LIMITATIONS.md @@ -91,17 +91,29 @@ There is no `fs.flock`, no `fcntl`, no `O_EXLOCK`, and no `LOCK_*` constant on a **Update (2026-08-26) — the zombie branch is now verified, not just reachable.** `pidAlive`'s `/proc`-based zombie check (a dead-but-unreaped owner reads as state `Z`) had been exercised by CI on Linux but never actually asserted against a real zombie — every prior test only ran it against live, non-zombie processes. `tests/state-store/pidalive-zombie/` closes that specific gap with a fixture that forges a genuine, self-verified Linux zombie (a `python3 os.fork()` child held unreaped) and asserts `pidAlive` returns `false` against it; see `.plans/v0.5.0/PIDALIVE-ZOMBIE-TEST-TRD-2026-08-24.md` for the design. This closes evidence for that one branch of `pidAlive` — it does not change the pid-reuse-across-namespaces or mtime-vs-wall-clock approximations described above, which remain. -## 9. The MCP shim seals a session it is handed — it does not capture live traffic +## 9. Three of the shim's four capture modes remain unbuilt — the local-proxy mode now exists -`scripts/gsa-mcp-shim.js` (`sealBoundaryBundle`) takes an already-assembled `session` object — `initialize`, the granted `tools[]`, and the `calls[]` already made — and maps it into a signed boundary-tier (profile A) attestation bundle. That is the whole of what exists: hashing, tracing, and sealing data that is passed in. **Nothing produces that `session` object from a live MCP connection.** +`scripts/gsa-mcp-shim.js` (`sealBoundaryBundle`) takes an already-assembled `session` object — `initialize`, the granted `tools[]`, and the `calls[]` already made — and maps it into a signed boundary-tier (profile A) attestation bundle. That contract is unchanged and still narrow: the shim itself never observes an MCP connection directly, only the `session` shape it is handed. -The design doc ([`.plans/v0.3.0/graphsmith-v0.3.0-mcp-attestation-shim-design.md`](.plans/v0.3.0/graphsmith-v0.3.0-mcp-attestation-shim-design.md), §4) describes four deployment modes for capturing real traffic: a local proxy sitting between an MCP client and its servers, a client-side plugin hooking an agent's MCP middleware, server-side middleware wrapping a tool server, and a gateway integration for enterprise MCP gateways. **None of the four exist in code.** There is no JSON-RPC interceptor, no stdio wrapper, no HTTP reverse proxy, and no gateway-attachment point anywhere in the tree — confirmed by grepping the current source for `gateway`/`proxy`/`interceptor`: the only hits are an unrelated word-count comment and a JS `Proxy`-trap comment, neither part of MCP capture. +**Update (2026-09-04) — deployment mode 1 (local proxy) is now built.** The design doc (`.plans/v0.3.0/graphsmith-v0.3.0-mcp-attestation-shim-design.md`, §4 -- **not present in this reviewed tree**; referenced here for historical continuity with mode 1's own design lineage, not as a resolvable link) describes four ways to produce that `session` object from a live MCP connection: a local proxy sitting between an MCP client and its servers, a client-side plugin hooking an agent's own MCP middleware, server-side middleware wrapping a tool server, and an integration point inside an enterprise MCP gateway. `scripts/gateway/` (`gateway.js`, `proxy.js`, `downstream.js`, `agent-transport.js`, `session.js`) is deployment mode 1: it sits between an agent and one or more real downstream MCP servers, speaks both legs of the protocol itself, builds the `session` object from the live traffic it proxies, and calls `chain.appendSession` (which calls `sealBoundaryBundle`) when each agent connection closes. See the Standalone Gateway TRD (`.plans/v0.5.0/STANDALONE-GATEWAY-TRD-2026-08-22.md` -- **not present in this reviewed tree**, same caveat as above) for its design and section 10 below for that gateway's own residual scope limit. -**What this costs, precisely.** A caller who wants a real boundary attestation for a live MCP session must build the capture themselves — record `initialize`, `tools/list`, and every `tools/call` into the `session` shape the shim expects — before `sealBoundaryBundle` can do anything. The shim's own selftest (`--selftest` in the same file) demonstrates this: it hand-constructs a `session` literal in the test rather than deriving one from a running MCP exchange, because there is no code path that would derive one. +**What still doesn't exist.** Modes 2–4 — a client-side plugin hooking an agent's own MCP middleware, server-side middleware wrapping a tool server, and an attachment point inside a THIRD-PARTY enterprise MCP gateway (distinct from this repo's own standalone one) — remain unbuilt. A caller who cannot or does not want to run this repo's own gateway process in front of their MCP traffic still has no capture path and must assemble the `session` object themselves, exactly as before this update. -*Control:* the shim's contract is narrow and honestly scoped — it never claims to observe anything beyond the `session` object it is given (the file's own header comment states the boundary-only, profile-A-never-full-profiles limit), and the produced bundle's `decision_record.md` states plainly that the agent's plan was not observed. There is no false "captured live" claim anywhere in the emitted bundle. +*Control:* the shim's contract is narrow and honestly scoped — it never claims to observe anything beyond the `session` object it is given (the file's own header comment states the boundary-only, profile-A-never-full-profiles limit), and the produced bundle's `decision_record.md` states plainly that the agent's plan was not observed. There is no false "captured live" claim anywhere in the emitted bundle, and this document no longer claims zero capture paths exist now that one does. -**What would be needed to do it properly:** implement one of the four deployment modes in §4 of the design doc — most naturally the local proxy, since it needs no changes to the agent or to any MCP server. That is unbuilt work, not a documentation gap. +**What would be needed to do it properly:** implement one of the remaining three deployment modes in §4 of the design doc, for callers who need capture without running this repo's own gateway process. That is unbuilt work, not a documentation gap. + +## 10. The standalone gateway's HTTP agent sessions are keyed by an explicit session ID, with a bounded idle-timeout lifecycle (updated 2026-09-08) + +**Superseded.** Until 2026-09-08 this section documented that `scripts/gateway/agent-transport.js`'s agent-facing HTTP listener identified a session by the underlying TCP socket (`req.socket`), and recorded a 2026-09-04 board decision to deliberately NOT build anything past that. Paul asked, informally, whether the HTTP transport should carry an explicit session identifier instead -- citing the MCP streamable-HTTP spec's own `Mcp-Session-Id` convention -- and said yes. Before building, this project sought a second opinion from an external panel of five non-Anthropic frontier models (openai/gpt-6-astra, x-ai/grok-4.5, google/gemini-3.1-pro-preview, deepseek/deepseek-v3.2, qwen/qwen3-max) on what scope was actually correct, since "yes" was compatible with a small labeling change or a much larger reconnect/resume rewrite. 4 of 5 recommended a socket-scoped ID that only fixes the naming/observability problem; x-ai/grok-4.5 dissented, arguing that leaves the actual pooling-proxy defect (below) unfixed and that a *coherent* minimal HTTP session model is not much more code. Paul chose grok-4.5's design. Full brief and all five raw responses: this project's decision log, entry "2026-09-08 -- external panel: HTTP agent session identity." + +**What's implemented now.** Sessions are keyed by an explicit, server-minted `Mcp-Session-Id` (128 random bits), minted on a header-less `initialize` and returned on the response header. Every later request on that session must echo the header; an unrecognized/expired ID gets 404. A session ends on an explicit `DELETE` (204), after 30 minutes of inactivity (fixed for now -- not yet a config field, see `scripts/gateway/agent-transport.js`'s own header comment on why), or when the gateway process shuts down (`gateway.js#stop` already force-closes every session still open in `proxy.sessions`, unconditionally). The TCP socket that happened to carry a given request is no longer part of session identity at all -- this is still `session_boundary: "connection"` (the schema's only implemented value; `"time_window"` is still rejected at startup), just with "connection" now meaning "this MCP session," not "this TCP socket." + +**What this fixes.** A connection-pooling reverse proxy multiplexing several distinct agents over one shared backend socket can no longer corrupt session identity (identity no longer comes from the socket at all), and one well-behaved agent whose HTTP client rotates connections mid-session (a recycled keep-alive socket, a client-side reconnect) no longer loses its already-`initialize`d session for a reason outside its control. + +**What this deliberately still does NOT do -- disclosed, not hidden.** This is not full HTTP session resumption. There is no cross-process session store (a session dies with the gateway process, same as before), no session replay/recovery after a crash, and no configurable idle-timeout window yet. An agent that vanishes uncleanly (crash, network partition, no `DELETE`) now leaves its session open -- and counted in `active_sessions`, and un-sealed in the audit chain -- for up to the 30-minute idle window, instead of the near-instant cleanup the old socket-close handler gave for free; that is the deliberate trade for no longer conflating "this TCP connection ended" with "this agent is done." Building the fuller reconnect/replay/multi-node session store that a real `time_window` boundary would eventually need remains out of scope until a concrete deployment defines that lifecycle for real -- the same discipline the original 2026-09-04 decision applied, now to a narrower remaining gap. + +*Control:* documented here, in `scripts/gateway/agent-transport.js`'s own header comment, and in the project's decision log (external panel brief + all five responses). --- *This document is publication-hygiene clean. See also the worked offline-verifier transcript in [`docs/examples/offline-verify.md`](docs/examples/offline-verify.md).* diff --git a/checks/register-gateway-sessions.js b/checks/register-gateway-sessions.js new file mode 100644 index 00000000..f45b70ac --- /dev/null +++ b/checks/register-gateway-sessions.js @@ -0,0 +1,224 @@ +/* GraphSmith gateway-session chain integrity walk (checks/register-gateway-sessions.js). + * SG-FR-7 from the Standalone Gateway TRD (.plans/v0.5.0/STANDALONE-GATEWAY-TRD-2026-08-22.md + * SS3.6/SS8): verifies the append-only, hash-chained gateway-session log SG-FR-5 writes + * (scripts/gateway/chain.js) -- same shape and discipline as checks/register-retention.js's + * walkRetention(), deliberately, per the TRD's own instruction ("A walkGatewaySessions(ctx) + * function, same shape as walkRetention"). This is a SEPARATE chain from adoption/retention + * (Standalone Gateway TRD SS0's grounding finding: no code connects gateway-session activity + * to the pre-existing adoption/retention system), so this is new, comparable-in-size work, + * not a byproduct of SG-FR-5 -- not a thin wrapper around register-retention.js. + * + * One real difference from retention-entry's design, stated because it changes what this + * verifier can catch that register-retention.js's cannot: gateway-session-entry's + * entry_sha256 is SELF-REFERENTIAL (a hash of the entry's own other fields -- see + * scripts/gateway/chain.js#computeEntrySha256), unlike retention-entry's packet_sha256 (an + * externally-supplied hash of a packet the entry merely retains, which register-retention.js + * has no way to recompute from the entry alone). That lets this walk distinguish, per the + * Standalone Gateway TRD's test plan items 16/17: + * - a TAMPERED entry (its own entry_sha256 no longer matches its own recomputed content, + * OR the next entry's prev_entry_sha256 no longer matches it) -- "broken link" + * - a DELETED entry (a sequence gap, e.g. seq 5 then seq 7, no 6) -- "sequence gap", + * reported with a DISTINCT reason so an operator can tell "something was removed" from + * "something was tampered with" (test plan item 17's explicit requirement). + * It also checks HEAD.json consistency against the chain's own tail (test plan item 12: a + * crash between chain.jsonl's append and HEAD.json's update, per SS3.6's specified write + * order, must be reported as an incomplete append needing recovery, never silently treated + * as "chain is fine, just short") and, when a bundle-existence check is supplied, that every + * chained bundle_id has a corresponding bundle file on disk. + * + * Discipline (mirrors register-retention.js exactly): + * - fail-closed: any broken link, sequence gap, bad shape, or head mismatch => failed. + * - no clock/randomness in the decision path; timestamps are evidence only, if present. + * - Report contract: { status, evidence[], assumptions[], failure_domain? }; pure. + * - Honest limit (A6, same as register-retention.js's): a privileged local attacker who + * rewrites both the chain and its own HEAD.json is out of scope for THIS verifier alone + * -- SG-FR-6 (remote anchoring, not yet implemented -- see scripts/gateway/chain.js's + * pushChainTailToRemoteAnchor stub) is the intended mitigation, same class as + * register-retention.js's own stated A6 limit. + * Zero-dep CJS, Node >= 18. Schema: schemas/gateway-session-entry.schema.json. + */ +"use strict"; + +const HEX64 = /^[0-9a-f]{64}$/; +const ENTRY_KEYS = ["schema_version", "seq", "bundle_id", "prev_entry_sha256", "entry_sha256"]; + +function entryShapeOk(e) { + if (!e || typeof e !== "object") return false; + for (const k of ENTRY_KEYS) { + if (!Object.prototype.hasOwnProperty.call(e, k)) return false; + } + for (const k of Object.keys(e)) if (ENTRY_KEYS.indexOf(k) === -1) return false; + if (e.schema_version !== "1.0") return false; + if (typeof e.seq !== "number" || !Number.isInteger(e.seq) || e.seq < 1) return false; + if (typeof e.bundle_id !== "string" || e.bundle_id.length === 0) return false; + if (!(e.prev_entry_sha256 === null || (typeof e.prev_entry_sha256 === "string" && HEX64.test(e.prev_entry_sha256)))) return false; + if (typeof e.entry_sha256 !== "string" || !HEX64.test(e.entry_sha256)) return false; + return true; +} + +const HEAD_KEYS = ["schema_version", "seq", "bundle_id", "entry_sha256"]; + +/** Same strict, fail-closed shape discipline as entryShapeOk (and scripts/gateway/ + * chain.js's own headShapeOk, which this deliberately mirrors -- this module stays + * dependency-free, per its own header, so it hand-rolls rather than requires it). Without + * this, a HEAD.json with a missing/wrong schema_version, extra fields, or an otherwise + * malformed shape could still slip through the tail comparison below and receive a clean + * "verified" verdict as long as its seq/bundle_id/entry_sha256 happened to match. */ +function headShapeOk(h) { + if (!h || typeof h !== "object") return false; + for (const k of HEAD_KEYS) { + if (!Object.prototype.hasOwnProperty.call(h, k)) return false; + } + for (const k of Object.keys(h)) if (HEAD_KEYS.indexOf(k) === -1) return false; + if (h.schema_version !== "1.0") return false; + if (typeof h.seq !== "number" || !Number.isInteger(h.seq) || h.seq < 1) return false; + if (typeof h.bundle_id !== "string" || h.bundle_id.length === 0) return false; + if (typeof h.entry_sha256 !== "string" || !HEX64.test(h.entry_sha256)) return false; + return true; +} + +/* ctx = { + * chain: [gateway-session-entry], // ordered append-only log, as read from chain.jsonl + * head: { schema_version, seq, bundle_id, entry_sha256 } | null, // HEAD.json's content + * computeEntrySha256: (entry) => hex64, // scripts/gateway/chain.js's function, injected + * // rather than required(), keeping this module + * // dependency-free like register-retention.js + * bundleExists?: (bundle_id) => boolean, // optional: checked against each chain entry + * } */ +function walkGatewaySessions(ctx) { + const evidence = []; + const assumptions = [ + "Chain integrity is verified offline (no network/clock/random in the decision path).", + "Honest limit (A6, mirrors register-retention.js's): a privileged local attacker who rewrites both the chain and HEAD.json together is out of scope for this verifier alone; SG-FR-6 remote anchoring (not yet implemented) is the intended mitigation for that class.", + ]; + const fail = (msg, domain) => ({ status: "failed", evidence, assumptions, failure_domain: domain || "untrusted-input", reason: msg }); + try { + if (!ctx || typeof ctx !== "object") return fail("no context"); + const chain = ctx.chain; + if (!Array.isArray(chain)) return fail("chain must be an array"); + if (typeof ctx.computeEntrySha256 !== "function") return fail("ctx.computeEntrySha256 function is required"); + + if (chain.length === 0) { + if (ctx.head) return fail("HEAD.json names a chain tail but chain.jsonl is empty -- incomplete/corrupt state", "trusted-core"); + return { status: "not-applicable", evidence, assumptions, reason: "empty gateway-session log -- nothing to verify" }; + } + + let prevHash = null; + let prevSeq = null; + for (let i = 0; i < chain.length; i++) { + const e = chain[i]; + if (!entryShapeOk(e)) return fail(`entry[${i}] has an invalid shape/type -- refusing (fail-closed)`); + + const recomputed = ctx.computeEntrySha256({ schema_version: e.schema_version, seq: e.seq, bundle_id: e.bundle_id, prev_entry_sha256: e.prev_entry_sha256 }); + if (recomputed !== e.entry_sha256) { + return fail(`entry[${i}] (seq=${e.seq}) entry_sha256 does not match its own recomputed content -- TAMPERED entry, distinct from a sequence gap`, "untrusted-input"); + } + + if (i === 0) { + /* Board decision 2026-09-04, PR #29 review "require the verified chain to start + * at sequence one": a first entry with seq > 1 and a null predecessor passes + * every OTHER check here (its own hash recomputes correctly, and there is no + * prior entry for a broken-link check to compare against) even though it is + * exactly the missing-genesis-prefix failure mode this schema's contiguous, + * monotonic sequence contract exists to catch -- e.g. a chain.jsonl truncated + * from the front. Both halves of "this is genuinely the chain root" must hold. */ + if (e.seq !== 1) return fail(`entry[0] has seq=${e.seq}, expected 1 -- chain does not start at its genesis entry (missing prefix), distinct from a broken hash link`); + if (e.prev_entry_sha256 !== null) return fail("entry[0].prev_entry_sha256 must be null (chain root)"); + } else { + if (e.seq !== prevSeq + 1) { + return fail(`entry[${i}] seq=${e.seq} expected ${prevSeq + 1} -- SEQUENCE GAP (an entry was removed), distinct from a broken hash link`, "untrusted-input"); + } + if (e.prev_entry_sha256 !== prevHash) { + return fail(`entry[${i}] does not chain to entry[${i - 1}] -- broken/mutated link, distinct from a sequence gap`, "untrusted-input"); + } + } + if (typeof ctx.bundleExists === "function" && !ctx.bundleExists(e.bundle_id)) { + return fail(`entry[${i}] (seq=${e.seq}) references bundle_id "${e.bundle_id}" with no corresponding bundle file on disk -- incomplete append or a deleted bundle`, "trusted-core"); + } + prevHash = e.entry_sha256; + prevSeq = e.seq; + } + evidence.push(`hash chain intact across ${chain.length} entry(ies); seq contiguous (append-only), starting at ${chain[0].seq}.`); + + const last = chain[chain.length - 1]; + if (!ctx.head) { + return fail(`chain.jsonl has ${chain.length} entry(ies) but HEAD.json is missing -- incomplete append (crash between the chain.jsonl write and the HEAD.json update), needs recovery`, "trusted-core"); + } + if (!headShapeOk(ctx.head)) { + return fail("HEAD.json has an invalid shape/type -- refusing (fail-closed)", "untrusted-input"); + } + if (!(ctx.head.seq === last.seq && ctx.head.bundle_id === last.bundle_id && ctx.head.entry_sha256 === last.entry_sha256)) { + return fail("HEAD.json does not match the chain's own tail entry -- incomplete append (crash between the chain.jsonl write and the HEAD.json update), needs recovery, never treated as merely short-but-fine", "trusted-core"); + } + evidence.push(`HEAD.json matches the chain tail (seq=${last.seq}, bundle_id=${last.bundle_id}).`); + + return { status: "verified", evidence, assumptions }; + } catch (e) { + return { status: "failed", evidence, assumptions, failure_domain: "trusted-core", reason: "exception during gateway-session walk -- failing closed: " + (e && e.message ? e.message : String(e)) }; + } +} + +const check = { + id: "register-gateway-sessions", + run(ctx) { + const r = walkGatewaySessions(ctx || {}); + const out = { status: r.status, evidence: r.evidence.slice(), assumptions: r.assumptions.slice() }; + if (r.failure_domain) out.failure_domain = r.failure_domain; + if (r.reason) out.evidence.push("reason: " + r.reason); + return out; + }, +}; + +module.exports = { ...check, walkGatewaySessions, entryShapeOk, headShapeOk }; + +if (require.main === module) { + if (process.argv.includes("--selftest")) { + const { computeEntrySha256 } = require("../scripts/gateway/chain.js"); + const h = (n) => String(n).padStart(2, "0").repeat(32); + const mk = (seq, prev, bundleId) => { + const partial = { schema_version: "1.0", seq, bundle_id: bundleId, prev_entry_sha256: prev }; + return { ...partial, entry_sha256: computeEntrySha256(partial) }; + }; + const e1 = mk(1, null, "gsa-0000000000000001"); + const e2 = mk(2, e1.entry_sha256, "gsa-0000000000000002"); + const e3 = mk(3, e2.entry_sha256, "gsa-0000000000000003"); + const chain = [e1, e2, e3]; + const head = { schema_version: "1.0", seq: 3, bundle_id: e3.bundle_id, entry_sha256: e3.entry_sha256 }; + + const good = check.run({ chain, head, computeEntrySha256 }); + // tamper the middle entry's own hash (does not recompute). + const tampered = [e1, { ...e2, entry_sha256: h(9) }, e3]; + const brokenSelf = check.run({ chain: tampered, head, computeEntrySha256 }); + // delete the middle entry -> sequence gap, distinct failure text from brokenSelf. + const gapChain = [e1, e3]; + const gap = check.run({ chain: gapChain, head: { ...head, seq: 3 }, computeEntrySha256 }); + // HEAD.json missing -> incomplete append. + const noHead = check.run({ chain, head: null, computeEntrySha256 }); + // HEAD.json stale (crash before update) -> incomplete append. + const staleHead = check.run({ chain, head: { schema_version: "1.0", seq: 2, bundle_id: e2.bundle_id, entry_sha256: e2.entry_sha256 }, computeEntrySha256 }); + // bundle file missing for a chained entry. + const missingBundle = check.run({ chain, head, computeEntrySha256, bundleExists: (id) => id !== e2.bundle_id }); + + const pass = + good.status === "verified" && + brokenSelf.status === "failed" && /TAMPERED/.test(brokenSelf.evidence.join(" ")) && + gap.status === "failed" && /SEQUENCE GAP/.test(gap.evidence.join(" ")) && + noHead.status === "failed" && /incomplete append/.test(noHead.evidence.join(" ")) && + staleHead.status === "failed" && /incomplete append/.test(staleHead.evidence.join(" ")) && + missingBundle.status === "failed" && /no corresponding bundle file/.test(missingBundle.evidence.join(" ")); + + console.log( + "register-gateway-sessions selftest:", pass ? "OK" : "FAIL", + "| chain=" + (good.status === "verified"), + "tampered-detected=" + (brokenSelf.status === "failed"), + "gap-detected=" + (gap.status === "failed"), + "no-head-detected=" + (noHead.status === "failed"), + "stale-head-detected=" + (staleHead.status === "failed"), + "missing-bundle-detected=" + (missingBundle.status === "failed") + ); + process.exit(pass ? 0 : 1); + } else { + console.error("Usage: node checks/register-gateway-sessions.js --selftest"); + process.exit(1); + } +} diff --git a/ci-suite-manifest.json b/ci-suite-manifest.json index 3e5509c6..be082b85 100644 --- a/ci-suite-manifest.json +++ b/ci-suite-manifest.json @@ -58,9 +58,18 @@ "full local run of scripts/ci-run-suites.js on this release candidate,", "96/96 gating suites green); listing them only removes the 4 discovery", "warnings and makes the classification reviewable, it does not change", - "which suites gate." + "which suites gate.", + "", + "2026-08-28 (Track 1.2, standalone gateway): tests/gateway/ is a new per-component", + "regression suite for the new scripts/gateway/*.js + checks/register-gateway-", + "sessions.js (Standalone Gateway TRD, SG-FR-1 through SG-FR-5 and SG-FR-7). It is a", + "real GraphSmith component per the RULE above, so it is listed as GATING explicitly", + "rather than relying on the fail-safe. Covers mode-selection gating, config schema", + "validation, session capture/correlation, hash-chain persistence + SG-FR-7's", + "verifier, proxy dispatch, and a real end-to-end child-process integration test." ], "gating_suites": [ + "tests/gateway/", "tests/manifest/", "tests/audit-replay/", "tests/report-integrity/", diff --git a/schemas/gateway-config.schema.json b/schemas/gateway-config.schema.json new file mode 100644 index 00000000..662edc1b --- /dev/null +++ b/schemas/gateway-config.schema.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://graphsmith.dev/schemas/gateway-config.schema.json", + "title": "GraphSmith standalone gateway configuration (Track 1.2, SG-FR-*)", + "description": "Configuration for the standalone MCP gateway process. Deliberately carries NO mode field and no reference to one -- mode selection lives entirely in the mode-selection contract's own dedicated file, .graphsmith/gateway-mode.json (Track 1.1, independently owned). See the Standalone Gateway TRD (.plans/v0.5.0/STANDALONE-GATEWAY-TRD-2026-08-22.md) section 4 for the frozen upstream design this schema implements verbatim, plus one disclosed addition: `agent_listen`, added by Track 1.2 because the frozen TRD's own architecture (section 2/3.1, 'stdio and/or HTTP' on the agent-facing side) requires knowing how THIS process listens for agents, but the TRD's literal section-4 JSON only specified the downstream side's transport. Everything else below is transcribed unchanged.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "state_dir", "downstream_servers", "signing_key_ref"], + "properties": { + "schema_version": { "const": "1.0" }, + "state_dir": { + "type": "string", + "minLength": 1, + "description": "Passed straight through to WriterClaim/state-store -- same directory convention as the rest of the pipeline." + }, + "host_id": { + "type": "string", + "minLength": 1, + "description": "Optional explicit override, per FR-8's collision-resistance discussion; required (fail startup without it) if this config is used for more than one instance." + }, + "downstream_servers": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "transport", "endpoint"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "transport": { "enum": ["stdio", "http"] }, + "endpoint": { "type": "string", "minLength": 1 }, + "token_ref": { + "type": "string", + "minLength": 1, + "description": "Optional. Env var name or file path holding the bearer token this downstream server's http transport requires -- never the raw token, matching signing_key_ref's and agent_listen.token_ref's own convention (board decision 2026-09-04: extend this schema with an optional auth field rather than push authenticated downstream servers out to a reverse-proxy inject). Only meaningful when transport is \"http\"; a stdio downstream ignores it if present." + } + } + } + }, + "signing_key_ref": { + "type": "string", + "minLength": 1, + "description": "A reference (env var name or file path), never the raw key material, per this codebase's existing secrets discipline." + }, + "session_boundary": { + "enum": ["connection", "time_window"], + "description": "Placeholder pending SS3.4's resolution -- explicit-signal not listed until that transport requirement is designed. This build (Track 1.2) resolves the open question by defaulting to 'connection' when omitted, per the TRD's own stated lean in SS10 OQ-1." + }, + "persistence": { + "enum": ["p2_hash_chained_log"], + "description": "P2, decided per SS0 -- session-to-session continuity is a confirmed real requirement." + }, + "remote_anchor_ref": { + "type": "string", + "minLength": 1, + "description": "Where the chain tail gets periodically pushed for SG-FR-6's remote-anchoring mitigation. NOT YET IMPLEMENTED in this build -- see scripts/gateway/chain.js's pushChainTailToRemoteAnchor() stub and the build report's named SG-FR-6 follow-up. Accepted and validated here so a deployment's config is forward-compatible, but currently inert." + }, + "agent_listen": { + "type": "object", + "additionalProperties": false, + "required": ["transport"], + "description": "Track-1.2-added (not in the TRD's literal SS4 JSON -- see this schema's top-level description). Governs how THIS gateway process accepts agent connections. Defaults to stdio (one process, one connection, matching this codebase's existing stdio-transport trust-boundary convention) when omitted.", + "properties": { + "transport": { "enum": ["stdio", "http"] }, + "port": { "type": "integer", "minimum": 1 }, + "host": { + "type": "string", + "minLength": 1, + "description": "Interface to bind the http transport's listener to. Defaults to \"127.0.0.1\" when omitted (CodeRabbit PR #29 review \"bind the agent HTTP listener to an explicit host\") -- omitting host from Node's server.listen() binds the wildcard address, exposing this bearer-token-protected listener on every network interface instead of just localhost." + }, + "token_ref": { + "type": "string", + "minLength": 1, + "description": "Env var name or file path holding the bearer token required of agents on the http transport -- never the raw token, matching signing_key_ref's own convention. Required when transport is http." + } + } + } + } +} diff --git a/schemas/gateway-session-entry.schema.json b/schemas/gateway-session-entry.schema.json new file mode 100644 index 00000000..3e084584 --- /dev/null +++ b/schemas/gateway-session-entry.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://graphsmith.dev/schemas/gateway-session-entry.schema.json", + "title": "GraphSmith gateway-session chain entry (Track 1.2, SG-FR-5/SG-FR-7)", + "description": "One record in the append-only, hash-chained gateway-session log -- modeled on schemas/retention-entry.schema.json's chain shape (see checks/register-retention.js), but a separate chain: gateway sessions are not adoption/retention records (Standalone Gateway TRD SS0's grounding finding). entry_sha256 is SELF-REFERENTIAL (computed from this entry's own other fields, see scripts/gateway/chain.js#computeEntrySha256), unlike retention-entry's packet_sha256 (an externally-supplied hash of a packet this entry merely retains) -- this is what lets SG-FR-7's verifier detect a mutated entry by recomputing its hash from its own content, not only by checking the next entry's prev_entry_sha256 link.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "seq", "bundle_id", "prev_entry_sha256", "entry_sha256"], + "properties": { + "schema_version": { "const": "1.0" }, + "seq": { "type": "integer", "minimum": 1, "description": "Monotonic position in the chain, contiguous (append-only)." }, + "bundle_id": { "type": "string", "minLength": 1, "description": "References the sealed bundle file this entry chains -- /gateway-sessions/.json, unaffected by this schema." }, + "prev_entry_sha256": { + "type": ["string", "null"], + "pattern": "^[a-f0-9]{64}$", + "description": "null only for seq==1 (chain root); otherwise the prior entry's entry_sha256." + }, + "entry_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$", + "description": "SHA-256 hex of this entry's own (schema_version, seq, bundle_id, prev_entry_sha256) tuple -- see scripts/gateway/chain.js#computeEntrySha256 for the exact canonical form." + } + } +} diff --git a/scripts/gateway/agent-transport.js b/scripts/gateway/agent-transport.js new file mode 100644 index 00000000..a75ea91f --- /dev/null +++ b/scripts/gateway/agent-transport.js @@ -0,0 +1,445 @@ +#!/usr/bin/env node +/* GraphSmith standalone gateway -- agent-facing transports (Standalone Gateway TRD + * SS3.1). Extracted out of gateway.js (which owns process lifecycle/writer-claim) to + * keep that file under this repo's default file-size guideline and because these two + * runners have no dependency on writer-claim/mode-gate/config -- only on a GatewayProxy. + * + * stdio: one process, one connection (mirrors mcp-server/src/stdioTransport.js's own + * newline-delimited JSON-RPC framing and "stdin closed -> exit cleanly" convention). + * + * http: sessions keyed by an explicit, server-minted `Mcp-Session-Id` (board decision + * 2026-09-08, revising the 2026-09-04 "key HTTP sessions by protocol identity" review + * item -- see runHttpAgentTransport's own header comment for the full design and why + * this supersedes the prior "one TCP socket = one session" identity). That prior + * identity model, and the explicit 2026-09-04 decision NOT to build anything past it, + * are preserved here only as history: KNOWN-LIMITATIONS.md documents what changed and + * why. + */ +"use strict"; + +const crypto = require("crypto"); +const http = require("http"); +const readline = require("readline"); +const { isAuthenticated } = require("../../mcp-server/src/auth.js"); +const { MAX_BODY_BYTES, REQUEST_TIMEOUT_MS } = require("../../mcp-server/src/httpTransport.js"); +const { DEFAULT_REQUEST_TIMEOUT_MS, MAX_HTTP_RESPONSE_BYTES, nextResidualLineBytes } = require("./downstream.js"); + +/** Runs the agent-facing stdio transport against `ctx.proxy`. Returns + * { connectionId, closed, stop, pushRequest } -- `closed` resolves once the session has + * already been finalized (the caller does not need to call closeConnection itself). + * + * `pushRequest(method, params, timeoutMs)` lets the gateway send the agent a request IT + * did not ask for and get back a promise for the agent's reply -- e.g. forwarding a + * downstream server's own `sampling/createMessage` request up to this agent's model + * (board decision 2026-09-04, PR #29 review "forward downstream sampling requests + * upstream"). This is deliberately stdio-only: stdio can write to the agent at any time, + * while the HTTP agent transport below is plain request/response with no way to push -- + * see runHttpAgentTransport's own header note and gateway.js's wiring, which leaves + * downstream-initiated requests erroring cleanly (never silently dropped) when the agent + * transport is HTTP. Pushed-request ids are namespaced ("gw-push-...") so they cannot + * collide with whatever id scheme the agent itself uses for its own requests, mirroring + * downstream.js's own "gateway-assigned id, independent of the other leg's ids" + * convention for the downstream leg. */ +function runStdioAgentTransport(ctx) { + const connectionId = "stdio-" + crypto.randomBytes(8).toString("hex"); + ctx.proxy.openConnection(connectionId); + /* Codex PR #29 review round 5 "handle a broken agent stdout pipe": mirrors + * downstream.js's connectStdio's own child.stdin "error" listener -- if the agent + * process on the other end of this stdio pair exits or closes its read side while a + * response is being written, process.stdout.write() can emit an asynchronous EPIPE (or + * similar) error. Node treats an unhandled "error" event on a stream as fatal and + * crashes the whole process; this listener exists solely to absorb the stream-level + * error event itself, so a vanished agent's own broken pipe cannot take down this + * gateway process -- the existing stdin "close" handler below already drives the real + * disconnect/cleanup path independently of this. */ + process.stdout.on("error", () => { /* best effort: absorbed to prevent an unhandled 'error' event from crashing the gateway */ }); + const rl = readline.createInterface({ input: process.stdin, terminal: false }); + /* Codex PR #29 review round 5 "bound newline-delimited input from stdio agents": + * mirrors downstream.js's connectStdio -- readline buffers an unbounded amount of + * input while waiting for a newline. downstream.js's own stdio path (the OTHER + * direction: gateway -> real MCP server) already bounds this; the agent-facing side + * (agent -> gateway, right here) previously did not, so a faulty or compromised stdio + * agent could grow this process's memory without limit by never terminating a line. + * Reuses the exact same pure helper and byte cap as downstream.js's own fix so both + * directions stay consistent, and reports the same JSON-RPC error shape as this + * transport's own parse-error handler below before closing the connection. */ + let bytesSinceLastLine = 0; + let stdinOverLimitHandled = false; + process.stdin.on("data", (chunk) => { + if (stdinOverLimitHandled) return; + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytesSinceLastLine = nextResidualLineBytes(bytesSinceLastLine, buf); + if (bytesSinceLastLine > MAX_HTTP_RESPONSE_BYTES) { + stdinOverLimitHandled = true; + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32600, message: `Agent stdin produced an unterminated line exceeding the ${MAX_HTTP_RESPONSE_BYTES}-byte limit -- closing the connection.` } }) + "\n"); + rl.close(); + } + }); + + const pendingPushed = new Map(); // gw-push id -> { resolve, reject, timer } + + rl.on("line", (line) => { + const trimmed = line.trim(); + if (trimmed.length === 0) return; + let msg; + try { + msg = JSON.parse(trimmed); + } catch (error) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: `Parse error: ${error.message}` } }) + "\n"); + return; + } + /* A reply to a request THIS gateway pushed to the agent (has an id matching one this + * transport itself minted, and no "method" -- a genuine agent-initiated request + * always has one) -- must NOT go through proxy.handleMessage, which only understands + * new agent-initiated requests and would otherwise reject this as a malformed + * envelope. */ + if (msg && typeof msg === "object" && !Array.isArray(msg) && Object.prototype.hasOwnProperty.call(msg, "id") && msg.id !== null && typeof msg.method !== "string" && pendingPushed.has(msg.id)) { + const { resolve, reject, timer } = pendingPushed.get(msg.id); + clearTimeout(timer); + pendingPushed.delete(msg.id); + /* Codex PR #29 review round 3 "validate agent replies to pushed sampling + * requests": this previously accepted any id-matching, method-less message as a + * valid reply, without requiring "jsonrpc": "2.0" or exactly one of "result"/ + * "error" -- mirroring the same gap downstream.js's connectStdio had (and already + * fixed) on its own response-correlation path. A malformed reply such as + * `{"id":"gw-push-..."}` would resolve as a successful `undefined` sampling result + * and be forwarded to the downstream, and be attested, as though it had genuinely + * succeeded. */ + const hasResult = Object.prototype.hasOwnProperty.call(msg, "result"); + const hasError = Object.prototype.hasOwnProperty.call(msg, "error"); + const wellFormed = msg.jsonrpc === "2.0" && (hasResult || hasError) && !(hasResult && hasError); + if (!wellFormed) { + reject(new Error(`agent's reply to pushed request (id ${JSON.stringify(msg.id)}) was not a well-formed JSON-RPC 2.0 response (missing/invalid "jsonrpc", or not exactly one of "result"/"error" present)`)); + } else if (hasError) { + /* CodeRabbit PR #29 review round 4 "a reply with a present but falsy error + * resolves as a successful undefined result": branching on msg.error's truthiness + * (rather than the hasError presence flag already computed above) let a reply + * shaped like {"jsonrpc":"2.0","id":"gw-push-...","error":null} pass the + * exactly-one-of-result-or-error check above and then fall through to resolve() + * with an undefined result -- the exact false-success outcome that check exists to + * prevent. Same class of bug already fixed in downstream.js's own response + * correlation. */ + reject(Object.assign(new Error((msg.error && msg.error.message) || "agent returned an error"), { rpcError: msg.error })); + } else { + resolve(msg.result); + } + return; + } + ctx.proxy.handleMessage(connectionId, msg).then((response) => { + if (response !== null) process.stdout.write(JSON.stringify(response) + "\n"); + }).catch((error) => { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg && msg.id, error: { code: -32603, message: error.message } }) + "\n"); + }); + }); + + function pushRequest(method, params, timeoutMs) { + const id = "gw-push-" + crypto.randomBytes(8).toString("hex"); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingPushed.delete(id); + reject(Object.assign(new Error(`agent did not respond to pushed "${method}" within ${timeoutMs || DEFAULT_REQUEST_TIMEOUT_MS}ms`), { code: "GATEWAY_AGENT_PUSH_TIMEOUT" })); + }, timeoutMs || DEFAULT_REQUEST_TIMEOUT_MS); + if (typeof timer.unref === "function") timer.unref(); + pendingPushed.set(id, { resolve, reject, timer }); + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + }); + } + + const closed = new Promise((resolve) => { + rl.on("close", async () => { + // Any request this gateway pushed to the agent and never got a reply for (the + // agent hung up first) must be rejected now, not left to time out minutes later + // against a connection that is already gone. + for (const [id, { reject, timer }] of pendingPushed.entries()) { + clearTimeout(timer); + reject(new Error("agent stdio disconnected before responding to a pushed request")); + } + pendingPushed.clear(); + await ctx.proxy.closeConnection(connectionId, "agent stdio disconnected"); + resolve(); + }); + }); + return { connectionId, closed, stop: () => rl.close(), pushRequest }; +} + +/** Runs the agent-facing HTTP transport (config `agent_listen.transport: "http"`). + * `token` is the already-resolved bearer token (see config.js#resolveSecretRef). + * + * **Session identity (board decision 2026-09-08).** Sessions are identified by an + * explicit, server-minted `Mcp-Session-Id` -- mirroring the MCP streamable-HTTP + * transport spec's own convention -- not by the underlying TCP socket (the prior + * design; see git history / KNOWN-LIMITATIONS.md for what this replaces and why). Only + * `initialize` may be sent without a session ID; the gateway mints one there, opens the + * proxy session under it, and returns it on the `Mcp-Session-Id` response header. Every + * later request on that session must echo the header back; an unrecognized or expired + * ID gets 404 (MCP's own convention for "this session is gone -- start over with + * initialize"). A session ends on an explicit `DELETE` (204), after + * SESSION_IDLE_TIMEOUT_MS of inactivity, or when the gateway process shuts down + * (gateway.js#stop already force-closes every session still open in `proxy.sessions` at + * that point, unconditionally, regardless of transport) -- deliberately NOT when the + * TCP socket that carried a given request happens to close. + * + * This was scoped deliberately narrower than "real HTTP session resumption": it fixes + * the two concrete problems an explicit ID actually needs to fix -- + * (1) a pooling reverse proxy multiplexing distinct agents onto one shared backend + * socket can no longer corrupt session identity, because identity no longer comes + * from the socket at all; + * (2) one well-behaved agent whose HTTP client rotates connections mid-session (a + * recycled keep-alive socket, a client-side reconnect) no longer loses its already- + * `initialize`d session for a reason entirely outside its control -- + * without reopening `session_boundary: "time_window"` (still refused at startup, see + * gateway.js) or building the fuller reconnect/replay/multi-node session store that + * mode would eventually need. Sought a second opinion from an external panel of five + * non-Anthropic frontier models before landing on this scope (2026-09-08); see the + * project's own decision log for the full brief and dissenting views. + * + * **Disclosed cost of this scope, not hidden:** an agent that vanishes uncleanly (crash, + * network partition, no DELETE) now leaves its session open -- and counted in + * `active_sessions`, and un-sealed in the audit chain -- for up to + * SESSION_IDLE_TIMEOUT_MS, instead of the near-instant cleanup the old socket-close + * handler gave for free. That is the deliberate trade for no longer conflating "this + * TCP connection ended" with "this agent is done." */ +function runHttpAgentTransport(ctx, listenConfig, token) { + /* Same hard requirement mcp-server/src/httpTransport.js#createHttpServer already + * enforces for its own HTTP listener: the config schema itself does not (and cannot, + * since token_ref is only a reference, not the resolved secret) bound the resolved + * token's strength, so this is the one place that can actually refuse a + * trivially-brute-forceable bearer token before binding a network-accessible socket. */ + if (!token || typeof token !== "string" || token.length < 16) { + throw new Error( + "graphsmith-gateway: refusing to start the agent-facing HTTP transport without a strong bearer " + + "token. agent_listen.token_ref must resolve to a value of at least 16 characters -- this is a " + + "hard requirement for any non-stdio agent transport, not a configurable-away default." + ); + } + + const SESSION_ID_HEADER = "mcp-session-id"; + /* Not yet exposed as a config field. A fixed, documented default matches this change's + * own "minimal, non-speculative" scope (see header comment) -- the same discipline the + * 2026-09-04 decision applied to `time_window` itself. Make it configurable once a + * real deployment needs a different value, not before. */ + const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000; + /* Codex PR #29 review "cap concurrently retained HTTP sessions": without a bound, a + * client that repeatedly sends headerless "initialize" (each one opens a brand-new + * session -- see the no-sessionId branch below) and never DELETEs could grow + * httpSessions/proxy.sessions/idle-timer state without limit, exhausting memory despite + * the per-request body-size cap. Same "fixed, non-speculative default" discipline as + * SESSION_IDLE_TIMEOUT_MS just above -- make it configurable once a real deployment + * needs a different number, not before. */ + const MAX_HTTP_SESSIONS = 10000; + + const httpSessions = new Map(); // sessionId -> { idleTimer } -- transport-level bookkeeping proxy.js has no reason to know about. + + function sealSession(sessionId, reason) { + const entry = httpSessions.get(sessionId); + if (!entry) return; + clearTimeout(entry.idleTimer); + httpSessions.delete(sessionId); + ctx.proxy.closeConnection(sessionId, reason).catch(() => {}); + } + + function touchSession(sessionId) { + const entry = httpSessions.get(sessionId); + if (!entry) return; + clearTimeout(entry.idleTimer); + entry.idleTimer = setTimeout(() => sealSession(sessionId, "agent HTTP session idle timeout exceeded"), SESSION_IDLE_TIMEOUT_MS); + if (typeof entry.idleTimer.unref === "function") entry.idleTimer.unref(); + } + + function openSession() { + /* 128 random bits, not the prior design's 64 -- an id that is now a client-visible, + * bearer-like session credential (rather than an internal WeakMap key nobody outside + * this process ever saw) warrants the larger, standard margin against guessing. */ + const id = "http-" + crypto.randomBytes(16).toString("hex"); + ctx.proxy.openConnection(id); + httpSessions.set(id, { idleTimer: null }); + touchSession(id); + return id; + } + + const server = http.createServer((req, res) => { + /* Mirrors mcp-server/src/httpTransport.js#createHttpServer's own contract (that + * module explicitly returns 405 for non-POST -- see its own comment on this): reject + * every method but POST/DELETE before authentication or body processing, so a + * GET/PUT/etc. intermediaries may treat as safe or replayable can never reach a + * side-effecting tools/call the way a POST-only endpoint would refuse to let it + * (board decision 2026-09-04, PR #29 review "reject non-POST requests on the agent + * HTTP listener"). DELETE is admitted alongside POST for explicit session + * termination (board decision 2026-09-08) -- it carries no JSON-RPC payload and is + * not itself a side-effecting call. */ + if (req.method !== "POST" && req.method !== "DELETE") { + res.writeHead(405, { "content-type": "application/json", allow: "POST, DELETE" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "Only POST and DELETE are supported on this endpoint." } })); + return; + } + /* Reuses mcp-server/src/auth.js's already-adversarially-reviewed + * isAuthenticated() (constant-time comparison via crypto.timingSafeEqual, fail- + * closed on a missing/malformed header or an unconfigured token) rather than a + * second, naive `===` string comparison, which would reopen exactly the + * timing-attack surface that module exists to close. Runs before any session lookup + * so an unauthenticated caller learns nothing about which session IDs exist. */ + if (!isAuthenticated(req.headers["authorization"], token)) { + res.writeHead(401, { "content-type": "application/json", "www-authenticate": "Bearer" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32001, message: "Unauthenticated." } })); + return; + } + /* Bounds an authenticated (or compromised) agent's request body/duration the same + * way mcp-server/src/httpTransport.js does for its own listener: an absolute + * per-request deadline (a slow-trickled body would never trip Node's default + * inactivity-based server timeout) plus a byte cap enforced by counting real bytes + * received, not string length (see that module's own header comment on why). Without + * this, one connection could exhaust memory or stay open indefinitely, including + * during graceful shutdown's drain. */ + const requestDeadline = setTimeout(() => req.destroy(), REQUEST_TIMEOUT_MS); + if (typeof requestDeadline.unref === "function") requestDeadline.unref(); + req.on("close", () => clearTimeout(requestDeadline)); + + const sessionIdHeader = req.headers[SESSION_ID_HEADER]; + + if (req.method === "DELETE") { + req.resume(); // no body expected; drain and discard whatever the client sends anyway + req.on("end", () => { + if (!sessionIdHeader) { + res.writeHead(400, { "content-type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32600, message: `DELETE requires the ${SESSION_ID_HEADER} header.` } })); + return; + } + if (!httpSessions.has(sessionIdHeader)) { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32001, message: "Unknown or already-ended session." } })); + return; + } + sealSession(sessionIdHeader, "agent explicitly terminated session (DELETE)"); + res.writeHead(204); + res.end(); + }); + return; + } + + const chunks = []; + let bytesReceived = 0; + let tooLarge = false; + req.on("data", (c) => { + bytesReceived += c.length; + if (bytesReceived > MAX_BODY_BYTES) { + tooLarge = true; + req.destroy(); + return; + } + chunks.push(c); + }); + req.on("end", () => { + if (tooLarge) return; // connection already destroyed, nothing to respond with + let msg; + try { + msg = JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch (error) { + res.writeHead(400, { "content-type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: `Parse error: ${error.message}` } })); + return; + } + + let sessionId = sessionIdHeader; + /* Codex PR #29 review "validate initialize before allocating an HTTP session": + * tracks whether THIS request is the one that just minted `sessionId` via + * openSession() below, so the handleMessage outcome can decide whether that brand + * new session actually earned its 30-minute idle slot. */ + let justOpened = false; + if (!sessionId) { + /* No session ID presented: the ONLY message this can legitimately be is + * "initialize" (board decision 2026-09-08) -- anything else means either a + * client bug or a stale session the server already forgot; both get the same + * clear, actionable error rather than proxy.js's generic "no open session" + * throw. */ + if (!msg || typeof msg !== "object" || msg.method !== "initialize") { + res.writeHead(400, { "content-type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: msg && msg.id, error: { code: -32600, message: `Missing ${SESSION_ID_HEADER} header -- a new session must begin with "initialize".` } })); + return; + } + if (httpSessions.size >= MAX_HTTP_SESSIONS) { + res.writeHead(503, { "content-type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: msg && msg.id, error: { code: -32000, message: `This gateway is already at its concurrent HTTP session limit (${MAX_HTTP_SESSIONS}); cannot start a new session right now.` } })); + return; + } + /* Codex PR #29 review "reject new HTTP sessions cleanly while draining": + * ctx.proxy.openConnection() (called inside openSession()) throws synchronously + * once stopAcceptingNewSessions() has run (writer-claim lost, or graceful + * shutdown draining) -- this call sat outside any try/catch, so that throw + * escaped this synchronous req "end" handler uncaught, crashing the process + * exactly when a clean, finished shutdown mattered most. Catch the expected + * refusal and answer it like any other "not accepting requests" case instead. */ + try { + sessionId = openSession(); + justOpened = true; + } catch (error) { + res.writeHead(503, { "content-type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: msg && msg.id, error: { code: -32000, message: `Cannot start a new session: ${error.message}` } })); + return; + } + } else if (!httpSessions.has(sessionId)) { + res.writeHead(404, { "content-type": "application/json", [SESSION_ID_HEADER]: sessionId }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: msg && msg.id, error: { code: -32001, message: "Unknown or expired session -- start a new session with \"initialize\"." } })); + return; + } else { + touchSession(sessionId); + } + + ctx.proxy.handleMessage(sessionId, msg).then((response) => { + /* Codex PR #29 review "validate initialize before allocating an HTTP session": + * `msg.method === "initialize"` above only checks the method NAME -- a request + * missing "jsonrpc": "2.0", carrying an invalid "id", or otherwise malformed + * still reached openSession() above and got a real session + 30-minute idle + * timer before proxy.handleMessage's own envelope validation rejected it. An + * authenticated client repeating that could exhaust all MAX_HTTP_SESSIONS slots + * with initialize attempts that were never going to succeed. Since this session + * was only just minted for this exact request, an error response here means its + * "initialize" never actually completed -- seal it immediately rather than + * leaving a dead session to occupy a slot until it idles out. */ + if (justOpened && response && response.error) { + sealSession(sessionId, `initialize failed validation: ${response.error.message}`); + } + if (response === null) { + res.writeHead(202, { [SESSION_ID_HEADER]: sessionId }); + res.end(); + } else { + res.writeHead(200, { "content-type": "application/json", [SESSION_ID_HEADER]: sessionId }); + res.end(JSON.stringify(response)); + } + }).catch((error) => { + if (justOpened) sealSession(sessionId, `initialize threw: ${error.message}`); + res.writeHead(500, { "content-type": "application/json", [SESSION_ID_HEADER]: sessionId }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: msg && msg.id, error: { code: -32603, message: error.message } })); + }); + }); + }); + + return new Promise((resolve, reject) => { + /* server.listen() emits "error" (not a thrown exception) on an async bind failure + * (port already in use, EACCES on a privileged port, etc.) -- without a one-shot + * handler here, that error had no rejection path, so startGateway()'s own try/catch + * never ran its cleanup and the default process crashed uncaught after already + * acquiring the writer claim and spawning downstream children (board decision + * 2026-09-04, PR #29 review "reject HTTP listener bind failures through the startup + * promise"). Removed once "listening" fires so a later, unrelated runtime error + * event on the same server doesn't also try to settle this already-settled promise. */ + function onError(error) { + server.removeListener("listening", onListening); + reject(Object.assign(new Error(`graphsmith-gateway: failed to start the agent-facing HTTP listener: ${error.message}`), { cause: error })); + } + function onListening() { + server.removeListener("error", onError); + resolve({ server, port: server.address().port }); + } + server.once("error", onError); + server.once("listening", onListening); + /* CodeRabbit PR #29 review "bind the agent HTTP listener to an explicit host": + * omitting `host` from Node's server.listen() binds Node's wildcard address (:: or + * 0.0.0.0), exposing this bearer-token-protected listener on every network interface + * instead of just the local machine. Default to loopback-only; an operator who + * genuinely needs a different bind interface can say so via agent_listen.host + * (schemas/gateway-config.schema.json / config.js's validateAgentListen). */ + server.listen(listenConfig.port || 0, listenConfig.host || "127.0.0.1"); + }); +} + +module.exports = { runStdioAgentTransport, runHttpAgentTransport }; diff --git a/scripts/gateway/chain.js b/scripts/gateway/chain.js new file mode 100644 index 00000000..d60a4fa0 Binary files /dev/null and b/scripts/gateway/chain.js differ diff --git a/scripts/gateway/config.js b/scripts/gateway/config.js new file mode 100644 index 00000000..981ae419 --- /dev/null +++ b/scripts/gateway/config.js @@ -0,0 +1,198 @@ +#!/usr/bin/env node +/* GraphSmith standalone gateway -- configuration loader (Standalone Gateway TRD SS4). + * + * Hand-rolls the validation this schema needs, mirroring scripts/state-store.js's own + * schemaErrors() (which does the same for a bigger schema) rather than reusing + * scripts/schema-validate.js -- that validator's SUPPORTED_KEYWORDS set is deliberately + * scoped to exactly what schemas/host-adapter.schema.json uses (no minLength/minItems), + * and gateway-config.schema.json needs both. Hand-rolling a second small, purpose-built + * validator matches this codebase's own stated preference (schema-validate.js's header: + * "a general-purpose JSON Schema validator ... would throw loudly rather than under- + * validate") over silently extending a validator scoped to a different schema's needs. + * + * Zero-dependency, Node >= 18. + */ +"use strict"; + +const fs = require("fs"); +const CONFIG_SCHEMA = require("../../schemas/gateway-config.schema.json"); + +function fail(message, code = "GATEWAY_CONFIG_INVALID") { + const error = new Error(message); + error.code = code; + return error; +} + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function validateDownstreamServer(entry, index, errors) { + const loc = `downstream_servers[${index}]`; + if (!isPlainObject(entry)) { + errors.push(`${loc} must be an object`); + return; + } + for (const key of ["name", "transport", "endpoint"]) { + if (!Object.prototype.hasOwnProperty.call(entry, key)) errors.push(`${loc}.${key} is required`); + } + /* token_ref (board decision 2026-09-04, PR #29 review): optional bearer-credential + * reference for an authenticated downstream http server -- e.g. this repo's own MCP + * HTTP server, which always requires one. Never required (an http downstream may be + * unauthenticated), matches signing_key_ref/agent_listen.token_ref's existing "env var + * name or file path, never the raw secret" convention rather than inventing a new one. */ + for (const key of Object.keys(entry)) { + if (!["name", "transport", "endpoint", "token_ref"].includes(key)) errors.push(`${loc}.${key} is not allowed`); + } + if (Object.prototype.hasOwnProperty.call(entry, "name") && (typeof entry.name !== "string" || entry.name.length === 0)) { + errors.push(`${loc}.name must be a non-empty string`); + } + if (Object.prototype.hasOwnProperty.call(entry, "transport") && !["stdio", "http"].includes(entry.transport)) { + errors.push(`${loc}.transport must be "stdio" or "http"`); + } + if (Object.prototype.hasOwnProperty.call(entry, "endpoint") && (typeof entry.endpoint !== "string" || entry.endpoint.length === 0)) { + errors.push(`${loc}.endpoint must be a non-empty string`); + } + if (Object.prototype.hasOwnProperty.call(entry, "token_ref") && (typeof entry.token_ref !== "string" || entry.token_ref.length === 0)) { + errors.push(`${loc}.token_ref must be a non-empty string`); + } +} + +function validateAgentListen(value, errors) { + const loc = "agent_listen"; + if (!isPlainObject(value)) { + errors.push(`${loc} must be an object`); + return; + } + if (!Object.prototype.hasOwnProperty.call(value, "transport")) errors.push(`${loc}.transport is required`); + for (const key of Object.keys(value)) { + if (!["transport", "port", "host", "token_ref"].includes(key)) errors.push(`${loc}.${key} is not allowed`); + } + if (Object.prototype.hasOwnProperty.call(value, "transport") && !["stdio", "http"].includes(value.transport)) { + errors.push(`${loc}.transport must be "stdio" or "http"`); + } + if (Object.prototype.hasOwnProperty.call(value, "port") && (!Number.isInteger(value.port) || value.port < 1)) { + errors.push(`${loc}.port must be an integer >= 1`); + } + if (Object.prototype.hasOwnProperty.call(value, "host") && (typeof value.host !== "string" || value.host.length === 0)) { + errors.push(`${loc}.host must be a non-empty string`); + } + if (value.transport === "http" && (typeof value.token_ref !== "string" || value.token_ref.length === 0)) { + errors.push(`${loc}.token_ref is required (and must be a non-empty string) when transport is "http"`); + } + if (Object.prototype.hasOwnProperty.call(value, "token_ref") && (typeof value.token_ref !== "string" || value.token_ref.length === 0)) { + errors.push(`${loc}.token_ref must be a non-empty string`); + } +} + +const TOP_LEVEL_KEYS = [ + "schema_version", "state_dir", "host_id", "downstream_servers", "signing_key_ref", + "session_boundary", "persistence", "remote_anchor_ref", "agent_listen", +]; + +/** Validates `config` against schemas/gateway-config.schema.json's shape, returning an + * array of human-readable error strings (empty = valid). */ +function validateConfigShape(config) { + const errors = []; + if (!isPlainObject(config)) return ["config must be a JSON object"]; + + for (const key of Object.keys(config)) { + if (!TOP_LEVEL_KEYS.includes(key)) errors.push(`${key} is not allowed`); + } + for (const key of ["schema_version", "state_dir", "downstream_servers", "signing_key_ref"]) { + if (!Object.prototype.hasOwnProperty.call(config, key)) errors.push(`${key} is required`); + } + if (Object.prototype.hasOwnProperty.call(config, "schema_version") && config.schema_version !== "1.0") { + errors.push(`schema_version must equal "1.0", got ${JSON.stringify(config.schema_version)}`); + } + if (Object.prototype.hasOwnProperty.call(config, "state_dir") && (typeof config.state_dir !== "string" || config.state_dir.length === 0)) { + errors.push("state_dir must be a non-empty string"); + } + if (Object.prototype.hasOwnProperty.call(config, "host_id") && (typeof config.host_id !== "string" || config.host_id.length === 0)) { + errors.push("host_id must be a non-empty string"); + } + if (Object.prototype.hasOwnProperty.call(config, "downstream_servers")) { + if (!Array.isArray(config.downstream_servers) || config.downstream_servers.length === 0) { + errors.push("downstream_servers must be a non-empty array"); + } else { + config.downstream_servers.forEach((entry, index) => validateDownstreamServer(entry, index, errors)); + const names = config.downstream_servers.filter(isPlainObject).map((entry) => entry.name).filter((n) => typeof n === "string"); + const seen = new Set(); + for (const name of names) { + if (seen.has(name)) errors.push(`downstream_servers contains a duplicate name "${name}" -- names must be unique so tools can be attributed unambiguously`); + seen.add(name); + } + } + } + if (Object.prototype.hasOwnProperty.call(config, "signing_key_ref") && (typeof config.signing_key_ref !== "string" || config.signing_key_ref.length === 0)) { + errors.push("signing_key_ref must be a non-empty string"); + } + if (Object.prototype.hasOwnProperty.call(config, "session_boundary") && !["connection", "time_window"].includes(config.session_boundary)) { + errors.push('session_boundary must be "connection" or "time_window"'); + } + if (Object.prototype.hasOwnProperty.call(config, "persistence") && config.persistence !== "p2_hash_chained_log") { + errors.push('persistence must be "p2_hash_chained_log"'); + } + if (Object.prototype.hasOwnProperty.call(config, "remote_anchor_ref") && (typeof config.remote_anchor_ref !== "string" || config.remote_anchor_ref.length === 0)) { + errors.push("remote_anchor_ref must be a non-empty string"); + } + if (Object.prototype.hasOwnProperty.call(config, "agent_listen")) { + validateAgentListen(config.agent_listen, errors); + } + return errors; +} + +/** Resolves signing_key_ref / token_ref conventions: if the named string is an existing + * file path, read it; otherwise treat it as an environment variable name. Never accepts + * raw key material as the ref itself (SS4's own stated secrets discipline). */ +function resolveSecretRef(ref, label) { + if (typeof ref !== "string" || ref.length === 0) throw fail(`${label} must be a non-empty string`, "INVALID_ARGUMENT"); + if (fs.existsSync(ref) && fs.statSync(ref).isFile()) { + const contents = fs.readFileSync(ref, "utf8").trim(); + if (contents.length === 0) { + throw fail(`${label} ("${ref}") names a file that is empty (or only whitespace).`, "GATEWAY_SECRET_REF_UNRESOLVED"); + } + return contents; + } + /* CodeRabbit PR #29 review round 4 "trim and reject whitespace-only environment + * variables": an unresolved (empty) reference throws below, but a *whitespace-only* + * value previously passed through unmodified -- a whitespace signing_key_ref then fails + * much later and less clearly inside crypto.createPrivateKey(), and a whitespace bearer + * token can pass this check yet never authenticate. Trim before the emptiness check, + * same as the file-secret branch above already does. */ + const rawValue = process.env[ref]; + const value = typeof rawValue === "string" ? rawValue.trim() : rawValue; + if (typeof value !== "string" || value.length === 0) { + throw fail(`${label} ("${ref}") does not name an existing file, and no environment variable "${ref}" is set.`, "GATEWAY_SECRET_REF_UNRESOLVED"); + } + return value; +} + +/** Loads and validates a gateway config file. Throws GATEWAY_CONFIG_INVALID naming every + * violation (fail-closed, never a partial/best-guess config) on any schema violation. */ +function loadConfig(configPath) { + let raw; + try { + raw = fs.readFileSync(configPath, "utf8"); + } catch (error) { + throw fail(`Gateway config not readable at ${configPath}: ${error.message}`, "GATEWAY_CONFIG_UNREADABLE"); + } + let config; + try { + config = JSON.parse(raw); + } catch (error) { + throw fail(`Gateway config at ${configPath} is not valid JSON: ${error.message}`, "GATEWAY_CONFIG_MALFORMED"); + } + const errors = validateConfigShape(config); + if (errors.length > 0) { + throw fail(`Gateway config at ${configPath} is invalid:\n - ${errors.join("\n - ")}`, "GATEWAY_CONFIG_INVALID"); + } + return config; +} + +module.exports = { + CONFIG_SCHEMA, + validateConfigShape, + loadConfig, + resolveSecretRef, +}; diff --git a/scripts/gateway/downstream.js b/scripts/gateway/downstream.js new file mode 100644 index 00000000..0528b080 --- /dev/null +++ b/scripts/gateway/downstream.js @@ -0,0 +1,987 @@ +#!/usr/bin/env node +/* GraphSmith standalone gateway -- downstream MCP server connections (Standalone + * Gateway TRD SS3.1/SS3.2). + * + * "The downstream-facing side (gateway -> real MCP server) needs its own client-side + * transport (the existing code is server-side only), matching whatever transport the + * downstream server(s) actually speak" (SS3.1). This implements a JSON-RPC 2.0 client + * for the two transports the config schema allows (stdio, http): + * - stdio: spawns the configured command, speaks newline-delimited JSON-RPC over its + * stdin/stdout -- the same wire framing mcp-server/src/stdioTransport.js implements + * server-side, mirrored here client-side (SS3.1: "reuse ... rather than reinventing + * wire framing", "the difference is behavioral, not transport-level"). + * - http: POSTs a JSON-RPC request body to the configured endpoint URL and parses the + * JSON response. A plain, generic JSON-RPC-over-HTTP request/response (not the + * Streamable-HTTP session/header-mirroring dialect mcp-server/src/httpTransport.js + * implements for ITS OWN callers -- that dialect is a property of one specific + * server implementation in this repo, not of "HTTP" as a downstream transport in + * general, so this client does not assume a downstream server speaks it). + * + * Correlation is by JSON-RPC id, generated internally per outgoing request -- a + * gateway-assigned id, independent of whatever id the agent-facing side used, so a + * gateway fronting multiple agent connections never collides ids on the downstream leg. + * + * Zero-dependency (child_process, readline, http, https, net are all stdlib), Node >= 18. + */ +"use strict"; + +const { spawn } = require("child_process"); +const http = require("http"); +const https = require("https"); +const readline = require("readline"); +const { URL } = require("url"); +const crypto = require("crypto"); +const { resolveSecretRef } = require("./config.js"); + +function fail(message, code = "GATEWAY_DOWNSTREAM_ERROR") { + const error = new Error(message); + error.code = code; + return error; +} + +const DEFAULT_REQUEST_TIMEOUT_MS = 30000; + +/* Codex PR #29 review round 8 "bound termination of stdio downstream children": how long + * connectStdio's close() waits for a SIGTERM'd child to actually exit before escalating to + * SIGKILL. Same "fixed, non-speculative default" discipline as every other *_TIMEOUT_MS + * constant in this file/gateway.js -- long enough for a well-behaved child to flush and + * exit cleanly, short enough that a child which traps/ignores SIGTERM cannot stall + * shutdown or startup-failure cleanup for long. */ +const STDIO_CHILD_TERMINATE_TIMEOUT_MS = 5000; + +/* The MCP protocol version this client negotiates on the downstream leg's own + * initialize handshake -- matches the literal version scripts/gateway/proxy.js selects + * on the agent-facing leg (both legs of this gateway speak the same one version). */ +const MCP_PROTOCOL_VERSION = "2025-06-18"; + +/* Codex PR #29 review round 8 "reject unsupported downstream protocol selections": an + * ALLOWLIST of protocol versions this client is actually verified compatible with -- + * deliberately not a bare `=== MCP_PROTOCOL_VERSION` equality-only check (a Set makes + * adding a second verified-compatible version later a matter of extending this list, not + * changing the check's own logic) and deliberately not the prior fully-permissive + * "any non-empty string" validation (5-model external panel, Paul-approved 2026-09-12). + * Seeded with just this client's own declared version for now: this codebase speaks + * exactly one protocol version end to end (see this constant's own header above and + * proxy.js's GATEWAY_PROTOCOL_VERSION), so nothing else is actually exercised or verified + * yet. */ +const SUPPORTED_DOWNSTREAM_PROTOCOL_VERSIONS = new Set([MCP_PROTOCOL_VERSION]); + +/* Mirrors mcp-server/src/protocol.js's own _meta key names and validateMeta() + * contract -- literal duplication rather than a cross-package require, matching this + * file's existing convention of mirroring wire-level constants rather than reaching + * across the scripts/ <-> mcp-server/ package boundary (see MCP_PROTOCOL_VERSION + * above, and the mirrored Streamable HTTP headers in connectHttp below). Needed + * because a plain JSON-RPC-over-HTTP downstream is stateless: mcp-server/src/ + * httpTransport.js hands handleMessage a FRESH connectionState on every single + * request, so connectionState.legacyInitialized from an earlier `initialize` call + * never carries over -- every tools/list, tools/call, and server/discover request + * over HTTP must carry its own complete _meta block or a downstream enforcing this + * repo's own stateless MCP contract (SEP-2575) rejects it with "_meta is required" + * even though this client's own `initialize` succeeded moments earlier (board + * decision 2026-09-04, PR #29 review "send stateless metadata to HTTP downstreams"). */ +const META_PROTOCOL_VERSION_KEY = "io.modelcontextprotocol/protocolVersion"; +const META_CLIENT_INFO_KEY = "io.modelcontextprotocol/clientInfo"; +const META_CLIENT_CAPABILITIES_KEY = "io.modelcontextprotocol/clientCapabilities"; +const STATELESS_META_METHODS = new Set(["tools/list", "tools/call", "server/discover"]); + +/* Mirrors mcp-server/src/protocol.js's own STATELESS_PROTOCOL_VERSION -- a DIFFERENT + * literal from MCP_PROTOCOL_VERSION above. That constant is the legacy handshake + * version this client negotiates via the top-level `initialize` call (mcp-server's own + * handleLegacyInitialize doesn't even check it); _meta's own protocolVersion key is + * validated against this separate stateless-protocol literal instead (mcp-server/src/ + * server.js's validateMeta), and the two must never be conflated -- sending the legacy + * version inside _meta is itself an UNSUPPORTED_PROTOCOL_VERSION error there. */ +const STATELESS_META_PROTOCOL_VERSION = "2026-07-28"; + +function buildStatelessMeta(options) { + return { + [META_PROTOCOL_VERSION_KEY]: STATELESS_META_PROTOCOL_VERSION, + [META_CLIENT_INFO_KEY]: options.clientInfo || { name: "graphsmith-standalone-gateway", version: "1.0" }, + [META_CLIENT_CAPABILITIES_KEY]: {}, + }; +} + +/* Safety cap on a single downstream HTTP response body, and the absolute wall-clock + * deadline (independent of the idle-socket timeout below, which only trips on + * inactivity) a response has to finish arriving -- board decision 2026-09-04, PR #29 + * review "bound downstream HTTP response bodies": without both, a compromised or + * faulty configured HTTP downstream could exhaust gateway memory (unbounded buffering) + * or keep a call alive indefinitely (a response that keeps trickling bytes never goes + * "inactive" long enough to trip req.setTimeout). */ +const MAX_HTTP_RESPONSE_BYTES = 10 * 1024 * 1024; + +/* Bounds on how many tools/list pages a downstream may hand back before this client + * gives up rather than looping forever on a downstream that returns a cursor cycle or an + * unbounded number of pages. */ +const MAX_TOOLS_LIST_PAGES = 1000; + +/* Bounds the CUMULATIVE size of a downstream's entire tools/list surface, summed across + * every page -- Codex PR #29 review round 5 "bound aggregate tool discovery data": + * MAX_HTTP_RESPONSE_BYTES already bounds any single page's response body, and + * MAX_TOOLS_LIST_PAGES bounds how many pages are followed, but neither bounds the + * cumulative bytes actually RETAINED in `tools` across those pages -- a downstream + * returning close to MAX_HTTP_RESPONSE_BYTES on every one of MAX_TOOLS_LIST_PAGES pages + * could still make this client buffer roughly 10 GiB before startup fails. A generous, + * fixed multiple of the per-page cap (not yet a config field -- same "fixed, + * non-speculative default" discipline as MAX_TOOLS_LIST_PAGES above and MAX_HTTP_SESSIONS + * in agent-transport.js; make it configurable once a real deployment needs a different + * number, not before). */ +const MAX_TOTAL_TOOLS_DESCRIPTOR_BYTES = 50 * 1024 * 1024; + +/** Pure helper for connectStdio's unterminated-line byte cap (CodeRabbit PR #29 review + * round 4 "make this regression test deterministic"): given the residual unterminated + * byte count carried over from the previous chunk and the newly-received chunk, returns + * the new residual -- the number of trailing bytes not yet followed by a newline. A + * chunk containing no newline extends the residual by its full length; a chunk + * containing one or more newlines resets the residual to just the bytes after its LAST + * newline. Exported separately from the "data" listener that calls it so a test can + * drive it directly with an explicit in-memory Buffer, independent of how an OS pipe + * happens to chunk a real child process's stdout. */ +function nextResidualLineBytes(prevResidualBytes, buf) { + const lastNewline = buf.lastIndexOf(0x0a); + return lastNewline === -1 ? prevResidualBytes + buf.length : buf.length - lastNewline - 1; +} + +/** Codex PR #29 review "keep gateway secrets out of downstream subprocess environments": + * spawn() with no explicit `env` inherits this process's COMPLETE process.env, so any + * configured stdio downstream previously received the resolved signing_key_ref / + * agent_listen.token_ref / downstream token_ref env vars even though it needs none of + * them -- a compromised or malicious stdio server could read the attestation signing key + * or the agent bearer token straight out of its own environment. Returns a shallow copy + * of process.env with exactly the gateway's own secret-holding variable NAMES (never + * their values, which this function never needs) deleted, so a stdio child still gets + * everything else (PATH, HOME, locale, its own unrelated env) unchanged. Returns + * `process.env` itself, not a copy, when there is nothing to strip. */ +function buildStdioChildEnv(secretEnvNames) { + if (!secretEnvNames || (typeof secretEnvNames.size === "number" && secretEnvNames.size === 0) || (Array.isArray(secretEnvNames) && secretEnvNames.length === 0)) { + return process.env; + } + const env = { ...process.env }; + for (const name of secretEnvNames) { + if (typeof name === "string" && name.length > 0) delete env[name]; + } + return env; +} + +/** A downstream connection over stdio: spawns `endpoint` (a shell command line, split on + * whitespace -- simplest form; a downstream needing shell quoting can wrap itself in a + * small launcher script) and speaks newline-delimited JSON-RPC over its stdio, matching + * stdioTransport.js's own framing. `options.secretEnvNames` (a Set/array of env var + * names), when provided, is stripped from the spawned child's environment -- see + * buildStdioChildEnv above. */ +function connectStdio(endpoint, options = {}) { + const parts = String(endpoint).trim().split(/\s+/); + const [command, ...args] = parts; + const child = spawn(command, args, { + stdio: ["pipe", "pipe", options.inheritStderr ? "inherit" : "ignore"], + env: buildStdioChildEnv(options.secretEnvNames), + }); + /* Codex PR #29 review round 8 "bound termination of stdio downstream children": + * overridable the same way every other per-connection option here is (a caller-supplied + * options field, not a bare module constant) so a test can exercise the SIGTERM -> + * SIGKILL escalation on a short, fast deadline instead of waiting out the real + * production default -- see close()'s own use of this below. */ + const terminateTimeoutMs = options.terminateTimeoutMs || STDIO_CHILD_TERMINATE_TIMEOUT_MS; + + const pending = new Map(); + let nextId = 1; + let closed = false; + let closeError = null; + const notificationHandlers = []; + + /* Codex PR #29 review round 3 "handle errors from the downstream stdin stream": a + * downstream that exits just before or during a write can make child.stdin emit an + * asynchronous EPIPE (or ERR_STREAM_WRITE_AFTER_END) error rather than throwing into + * the call()/notify()/write-back call sites below -- Node treats an unhandled "error" + * event on a stream as fatal and crashes the whole gateway process. Every write already + * goes through call()/notify()'s own `closed` guard or the unhandled-method write below + * (now also guarded, see there), so the pending calls this would have failed are already + * rejected via the child's own "close"/"error" handlers; this listener exists solely to + * absorb the stream-level error event itself so it cannot crash the process. */ + child.stdin.on("error", () => { /* best effort: absorbed to prevent an unhandled 'error' event from crashing the gateway */ }); + + const rl = readline.createInterface({ input: child.stdout, terminal: false }); + /* Codex PR #29 review round 3 "bound newline-delimited responses from stdio + * downstreams": readline buffers an unbounded amount of input while waiting for a + * newline, so a faulty or compromised downstream that never terminates a line could + * exhaust gateway memory even though the HTTP path already caps a single response at + * MAX_HTTP_RESPONSE_BYTES. Track bytes received since the last completed line and + * force-close the connection if a single unterminated line grows past that same cap. */ + let bytesSinceLastLine = 0; + /* CodeRabbit PR #29 review round 4 "track only the bytes after the last newline": + * readline registers its own "data" listener on child.stdout before this one, so for a + * chunk containing one or more COMPLETE lines, readline has already emitted "line" + * (resetting bytesSinceLastLine to 0 below) by the time this listener runs -- which then + * added the chunk's FULL length on top of that reset, rather than only the bytes still + * unterminated after the chunk's last newline. Several complete, individually + * well-formed lines delivered together in one chunk (e.g. pipelined/batched downstream + * writes) could therefore be misreported as a single oversized unterminated line and + * force-close a perfectly healthy connection. Derive the residual directly from each + * chunk's own last newline instead of relying on ordering against readline's listener. + * + * CodeRabbit PR #29 review round 4 "make this regression test deterministic": the + * regression test for the bug above drove this indirectly through a real child + * process's stdout, relying on a single process.stdout.write() call arriving as one + * "data" chunk on this side -- which the OS pipe is free to split into many smaller + * chunks regardless (and typically does, e.g. Linux's default ~64KB pipe buffer), so + * the test could pass against the OLD buggy logic too without ever exercising the + * multiple-complete-lines-in-one-chunk case it claims to cover. Extracted into this + * pure, exported helper so a test can drive it directly with an explicit in-memory + * Buffer -- deterministic, no child process or OS pipe chunking involved. */ + child.stdout.on("data", (chunk) => { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytesSinceLastLine = nextResidualLineBytes(bytesSinceLastLine, buf); + if (bytesSinceLastLine > MAX_HTTP_RESPONSE_BYTES) { + bytesSinceLastLine = 0; + if (!closed) { + closeError = fail(`downstream stdio produced an unterminated line exceeding the ${MAX_HTTP_RESPONSE_BYTES}-byte limit`, "GATEWAY_DOWNSTREAM_MALFORMED_RESPONSE"); + close(); + } + } + }); + /* Correlation order matters: a bidirectional stdio connection gives the downstream + * its OWN independent id namespace for requests it initiates (see the sampling-relay + * comment below), so a downstream-initiated request can legitimately reuse a numeric + * id this client currently has pending on the OTHER direction. Requiring the absence + * of "method" before treating a message as "the response to one of our own calls" + * (rather than checking `pending.has(msg.id)` alone) is what keeps those two + * directions from being confused with each other (board decision 2026-09-04, PR #29 + * review "distinguish downstream requests before correlating responses"). */ + rl.on("line", (line) => { + // bytesSinceLastLine is now derived per-chunk (from each chunk's own last newline) in + // the "data" listener above, so it needs no reset here. + const trimmed = line.trim(); + if (trimmed.length === 0) return; + let msg; + try { + msg = JSON.parse(trimmed); + } catch (error) { + return; // malformed line from downstream: not this client's job to crash on it + } + if (msg && Object.prototype.hasOwnProperty.call(msg, "id") && msg.id !== null && !Object.prototype.hasOwnProperty.call(msg, "method") && pending.has(msg.id)) { + const { resolve, reject, timer } = pending.get(msg.id); + clearTimeout(timer); + pending.delete(msg.id); + /* Codex PR #29 review "reject malformed stdio response envelopes": this branch + * previously accepted ANY id-matching, method-less message as a valid response -- + * unlike connectHttp's own fail-closed envelope check (jsonrpc/id/shape validated + * below), a downstream that omits "jsonrpc": "2.0" or sends neither "result" nor + * "error" would still be resolved/rejected as if it were well-formed, sealing a + * malformed exchange as a normal successful (or normally-failed) tool result. */ + const hasResult = Object.prototype.hasOwnProperty.call(msg, "result"); + const hasError = Object.prototype.hasOwnProperty.call(msg, "error"); + /* Codex PR #29 review round 5 "require exactly one result or error in stdio + * replies": the prior check accepted a message carrying BOTH fields, or an + * "error" key present but falsy (e.g. {"error":null}) -- branching on msg.error's + * truthiness rather than the hasError presence flag let that second shape fall + * through to the success branch and resolve(msg.result) with an undefined result, + * the same false-success outcome the "neither present" half of this check already + * guards against. Require exactly one of "result"/"error" as an own property and + * branch on presence, matching connectHttp's own envelope check just above (and + * agent-transport.js's own pushed-reply correlation, fixed the same way). */ + /* Codex PR #29 review round 6 "validate the error object before dereferencing it": + * hasError is a presence check, not a shape check -- {"error":null} satisfies it, + * and the round-5 fix above only ruled out "both present" / "neither present", not + * an "error" value that is present but not itself a JSON-RPC error object. Without + * this, the hasError branch below dereferenced msg.error.message unconditionally, + * throwing a TypeError from this readline "line" callback (outside the call's own + * promise) and crashing the whole gateway process on a malformed stdio reply. + * Mirrors connectHttp's own errObj shape guard just below in this file. */ + const errorIsValidObject = hasError && msg.error !== null && typeof msg.error === "object" && !Array.isArray(msg.error); + const wellFormed = msg.jsonrpc === "2.0" && (hasResult || hasError) && !(hasResult && hasError) && (!hasError || errorIsValidObject); + if (!wellFormed) { + reject(fail(`downstream stdio response for id ${JSON.stringify(msg.id)} was not a well-formed JSON-RPC 2.0 response (missing/invalid "jsonrpc", not exactly one of "result"/"error" present, or "error" present but not a JSON-RPC error object)`, "GATEWAY_DOWNSTREAM_MALFORMED_RESPONSE")); + } else if (hasError) { + reject(Object.assign(fail(msg.error.message || "downstream error", "GATEWAY_DOWNSTREAM_RPC_ERROR"), { rpcError: msg.error })); + } else { + resolve(msg.result); + } + } else if (msg && typeof msg.method === "string" && !Object.prototype.hasOwnProperty.call(msg, "id")) { + for (const handler of notificationHandlers) handler(msg); + } else if (msg && typeof msg.method === "string" && Object.prototype.hasOwnProperty.call(msg, "id") && msg.id !== null) { + /* An unsolicited REQUEST *from* the downstream server itself (has both a method + * and an id -- distinct from a notification, which has no id, and distinct from a + * response to something this client called, which has no method). The MCP + * sampling capability (`sampling/createMessage`) is the motivating case: a + * downstream server asks the connected agent's own model to do inference. + * Previously fell through both branches above and was silently dropped -- neither + * a recognized response nor a notification (board decision 2026-09-04, PR #29 + * review "forward downstream sampling requests upstream"). `options.onRequest`, + * when provided, decides how (or whether) to answer it; this transport client + * itself stays a dumb pipe and always writes back whatever response object + * onRequest resolves to, so the downstream never hangs waiting on an id that will + * never come back. */ + /* Codex PR #29 re-triage "validate downstream requests before forwarding + * sampling": this branch previously admitted any object with a string `method` and + * a non-null `id` -- it need not carry `jsonrpc: "2.0"` or a valid identifier type, + * so a faulty downstream could send a malformed object like {"id":1,"method": + * "sampling/createMessage"} and still reach options.onRequest, triggering a real + * agent model invocation and an attested call from traffic that was never a + * conforming JSON-RPC request. Mirrors proxy.js#handleMessage's own envelope/id-type + * validation (same -32600 code, same "string or finite safe-integer number" id + * rule) rather than inventing a new one -- validated here, before onRequest, not + * forwarded. */ + const idType = typeof msg.id; + const validId = idType === "string" || (idType === "number" && Number.isFinite(msg.id) && Number.isSafeInteger(msg.id)); + if (msg.jsonrpc !== "2.0" || !validId) { + if (!closed) { + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: validId ? msg.id : null, error: { code: -32600, message: `Malformed JSON-RPC 2.0 request envelope from downstream (missing/invalid "jsonrpc", or "id" not a string or finite safe-integer number).` } }) + "\n"); + } + } else if (typeof options.onRequest === "function") { + Promise.resolve(options.onRequest(msg)) + .then((response) => { + if (response && !closed) child.stdin.write(JSON.stringify(response) + "\n"); + }) + .catch((error) => { + if (!closed) child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, error: { code: -32603, message: error.message } }) + "\n"); + }); + } else if (!closed) { + /* Codex PR #29 review round 3 "handle errors from the downstream stdin stream": + * this branch previously wrote unconditionally, unlike the two onRequest branches + * above it -- a downstream that has already exited (closed=true, e.g. its own + * "close"/"error" handlers already ran) would still attempt a write here, which + * could throw synchronously or, with the stdin error listener above absorbing the + * async case, would otherwise be a pointless write to a stream nobody reads from + * anymore. Consistent with those two branches' own `!closed` guard. */ + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: `This gateway does not handle downstream-initiated method "${msg.method}".` } }) + "\n"); + } + } else if (msg && Object.prototype.hasOwnProperty.call(msg, "id") && msg.id !== null && !Object.prototype.hasOwnProperty.call(msg, "method")) { + /* Codex PR #29 review "surface unmatched stdio responses to the session recorder": + * a response whose id has no live entry in `pending` above (already timed out, or + * the downstream fabricated/replayed an id this client never sent) previously hit + * neither branch above and fell through to the comment below -- which PROMISES the + * session-correlation layer records this as an anomaly, but nothing actually called + * into it. `options.onUnmatchedResponse`, when provided, is this transport client's + * only way to surface the observed protocol violation upward; this file still stays + * a dumb pipe and does not itself decide how (or whether) it gets attributed to a + * session. */ + if (typeof options.onUnmatchedResponse === "function") options.onUnmatchedResponse(msg); + } + // Any other shape (a response missing "id", etc.) is silently ignored at this layer. + }); + + const closedPromise = new Promise((resolve) => { + child.on("close", (code) => { + closed = true; + closeError = fail(`downstream process exited (code ${code})`, "GATEWAY_DOWNSTREAM_DISCONNECTED"); + for (const [, { reject, timer }] of pending.entries()) { + clearTimeout(timer); + reject(closeError); + } + pending.clear(); + resolve(code); + }); + }); + child.on("error", (error) => { + closed = true; + closeError = fail(`downstream process failed to start: ${error.message}`, "GATEWAY_DOWNSTREAM_DISCONNECTED"); + }); + + function call(method, params, timeoutMs, onIdAssigned) { + if (closed) return Promise.reject(closeError || fail("downstream connection is closed", "GATEWAY_DOWNSTREAM_DISCONNECTED")); + const id = nextId++; + if (typeof onIdAssigned === "function") onIdAssigned(id); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(fail(`downstream call "${method}" timed out after ${timeoutMs || DEFAULT_REQUEST_TIMEOUT_MS}ms`, "GATEWAY_DOWNSTREAM_TIMEOUT")); + }, timeoutMs || DEFAULT_REQUEST_TIMEOUT_MS); + if (typeof timer.unref === "function") timer.unref(); + pending.set(id, { resolve, reject, timer }); + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + }); + } + + function notify(method, params) { + if (closed) return; + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n"); + } + + /** Cancels a still-pending call by the internal id `call()` assigned it (via + * `onIdAssigned`): rejects the caller's promise locally and best-effort notifies the + * downstream process so it can stop doing the (possibly expensive/irreversible) work, + * per MCP's notifications/cancelled. No-op (returns false) if the id is no longer + * pending -- already resolved/rejected/timed out. */ + function cancel(id) { + const entry = pending.get(id); + if (!entry) return false; + clearTimeout(entry.timer); + pending.delete(id); + entry.reject(fail(`downstream call (id ${id}) was cancelled`, "GATEWAY_DOWNSTREAM_CANCELLED")); + notify("notifications/cancelled", { requestId: id }); + return true; + } + + function close() { + /* CodeRabbit PR #29 review "set closed inside connectStdio's close() method": + * closed was previously only set by the child's own "close"/"error" events, which + * fire asynchronously after this function returns. In that gap, call()/notify()'s own + * `if (closed) return...` guards (above) would not yet trip, so a caller invoking + * either between this close() and the child actually exiting could still write to + * child.stdin after .end() -- child.stdin has no "error" listener, so that write + * throws as an unhandled stream error. Set the flag synchronously, first. */ + closed = true; + if (!closeError) closeError = fail("downstream connection is closed", "GATEWAY_DOWNSTREAM_DISCONNECTED"); + try { child.stdin.end(); } catch (error) { /* best effort */ } + try { child.kill(); } catch (error) { /* best effort: SIGTERM */ } + /* Codex PR #29 review round 8 "bound termination of stdio downstream children": this + * used to send SIGTERM once above and return synchronously without ever confirming the + * child actually exited. A configured stdio downstream that traps or ignores SIGTERM + * would then keep running (and keep its pipes open) indefinitely -- on a later startup + * failure, gateway.js's own cleanup only sets process.exitCode, so a still-open child + * can keep the whole gateway process alive well past that point. Wait (bounded by + * STDIO_CHILD_TERMINATE_TIMEOUT_MS) for the child's own "close" event (closedPromise, + * set up above); escalate to SIGKILL -- which a process cannot trap or ignore -- if it + * hasn't exited by the deadline. Returns a promise so a caller (gateway.js's shutdown + * and startup-failure cleanup) can actually await termination instead of treating it as + * fire-and-forget. */ + return Promise.race([ + closedPromise, + new Promise((resolve) => { + const timer = setTimeout(() => { + try { child.kill("SIGKILL"); } catch (error) { /* best effort */ } + resolve(); + }, terminateTimeoutMs); + if (typeof timer.unref === "function") timer.unref(); + }), + ]).then(() => undefined); + } + + return { + transport: "stdio", + call, + notify, + cancel, + close, + onNotification: (handler) => notificationHandlers.push(handler), + isClosed: () => closed, + whenClosed: () => closedPromise, + }; +} + +/** A downstream connection over plain JSON-RPC-over-HTTP: each call is one POST, one + * parsed JSON response. There is no persistent "connection" to hold open -- `close()` is + * a no-op provided for interface symmetry with connectStdio. */ +function connectHttp(endpoint, options = {}) { + const url = new URL(endpoint); + const client = url.protocol === "https:" ? https : http; + let nextId = 1; + let closed = false; + /* Distinct from `closed` (a local, caller-driven "we're done with this connection" + * flag): this tracks the last OBSERVED network outcome, so buildHealthStatus's + * reachability signal reflects reality instead of a flag that only ever gets set by + * this client's own close(). Starts true (optimistic) since no request has failed yet; + * a single request error or timeout flips it false, and the next request that actually + * completes (success OR a well-formed RPC error -- either proves the endpoint is up) + * flips it back true. */ + let reachable = true; + const notificationHandlers = []; // never fired: plain request/response HTTP has no server push + + function call(method, params, timeoutMs, onIdAssigned) { + if (closed) return Promise.reject(fail("downstream connection is closed", "GATEWAY_DOWNSTREAM_DISCONNECTED")); + const id = nextId++; + if (typeof onIdAssigned === "function") onIdAssigned(id); + const isStatelessMetaMethod = STATELESS_META_METHODS.has(method); + /* See buildStatelessMeta's header comment: this leg is stateless per-request, so + * every request under a method the stateless MCP protocol requires _meta on gets + * one attached here, centrally, rather than at each call site -- a caller-supplied + * _meta (if any) wins over the generated one, field by field. */ + const effectiveParams = isStatelessMetaMethod + ? { ...(params || {}), _meta: { ...buildStatelessMeta(options), ...((params && params._meta) || {}) } } + : params; + /* The mirrored header (below) must agree with whatever protocol version this + * request's own body actually declares -- STATELESS_META_PROTOCOL_VERSION for a + * _meta-carrying request, the legacy MCP_PROTOCOL_VERSION for "initialize" (which + * carries no _meta at all) -- or mcp-server's own header/body agreement check + * (validateMirroredHeaders) rejects the disagreement with HEADER_MISMATCH. */ + const declaredProtocolVersion = isStatelessMetaMethod ? STATELESS_META_PROTOCOL_VERSION : MCP_PROTOCOL_VERSION; + const body = JSON.stringify({ jsonrpc: "2.0", id, method, params: effectiveParams }); + return new Promise((resolve, reject) => { + let settled = false; + const settleResolve = (value) => { if (!settled) { settled = true; clearTimeout(absoluteDeadline); resolve(value); } }; + const settleReject = (error) => { if (!settled) { settled = true; clearTimeout(absoluteDeadline); reject(error); } }; + /* Absolute wall-clock deadline on receiving the COMPLETE response body, separate + * from req.setTimeout below (which only trips on socket INACTIVITY -- a downstream + * that keeps trickling bytes slowly enough to stay "active" would never trip it, + * per MAX_HTTP_RESPONSE_BYTES's header comment). */ + const absoluteDeadline = setTimeout(() => { + reachable = false; + req.destroy(); + settleReject(fail(`downstream HTTP response deadline exceeded after ${timeoutMs || DEFAULT_REQUEST_TIMEOUT_MS}ms`, "GATEWAY_DOWNSTREAM_TIMEOUT")); + }, timeoutMs || DEFAULT_REQUEST_TIMEOUT_MS); + if (typeof absoluteDeadline.unref === "function") absoluteDeadline.unref(); + const req = client.request( + url, + { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": Buffer.byteLength(body), + /* Mirrors the Streamable HTTP "Request Metadata" headers this repo's own MCP + * HTTP server requires on every request (mcp-server/src/httpTransport.js) -- + * a downstream that enforces the same contract (including this repo's own + * server, fronted as a downstream) would otherwise reject every request for + * missing headers regardless of authentication. */ + "mcp-protocol-version": declaredProtocolVersion, + "mcp-method": method, + ...(method === "tools/call" && params && typeof params.name === "string" ? { "mcp-name": params.name } : {}), + ...(options.headers || {}), + }, + }, + (res) => { + const chunks = []; + let bytesReceived = 0; + /* Codex PR #29 review round 6 "reject truncated HTTP responses immediately": a + * downstream that sends response headers and part of the body, then resets the + * connection, ends this response stream via "aborted"/"error" -- NOT "end" -- and + * the ClientRequest's own "error" handler (above/below) does not reliably fire + * once headers have already arrived on some Node versions/transports. Without a + * listener here, a mid-response disconnect previously fell through to no handler + * at all and the call sat pending until the absolute deadline (default 30s) + * instead of failing fast on a disconnect the gateway already observed. */ + const onPrematureEnd = (error) => { + if (settled) return; + reachable = false; + settleReject(fail(`downstream HTTP response ended prematurely${error ? `: ${error.message}` : ""}`, "GATEWAY_DOWNSTREAM_DISCONNECTED")); + }; + res.on("aborted", () => onPrematureEnd(null)); + res.on("error", (error) => onPrematureEnd(error)); + res.on("data", (chunk) => { + if (settled) return; + bytesReceived += chunk.length; + if (bytesReceived > MAX_HTTP_RESPONSE_BYTES) { + reachable = false; + req.destroy(); + settleReject(fail(`downstream HTTP response exceeded the ${MAX_HTTP_RESPONSE_BYTES}-byte limit`, "GATEWAY_DOWNSTREAM_MALFORMED_RESPONSE")); + return; + } + chunks.push(chunk); + }); + res.on("end", () => { + if (settled) return; + let parsed; + try { + parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch (error) { + settleReject(fail(`downstream HTTP response was not valid JSON: ${error.message}`, "GATEWAY_DOWNSTREAM_MALFORMED_RESPONSE")); + return; + } + /* Fail-closed on the response envelope itself (matching the stdio path's own + * strict id-correlation discipline): a wrong/missing JSON-RPC id, a wrong/ + * missing "jsonrpc" version, or a non-response object are all rejected rather + * than attested as this call's real result. Codex PR #29 review round 3 + * "require a result or error in HTTP responses": this previously stopped short + * of the stdio path's own "neither/both result-and-error present" check, so an + * envelope with the right jsonrpc/id but no "result" and no "error" (e.g. + * `{"jsonrpc":"2.0","id":1}`) fell through to the success branch below and + * resolved `undefined` as though the call had genuinely succeeded. */ + const hasResult = parsed && typeof parsed === "object" && Object.prototype.hasOwnProperty.call(parsed, "result"); + const hasError = parsed && typeof parsed === "object" && Object.prototype.hasOwnProperty.call(parsed, "error"); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || parsed.jsonrpc !== "2.0" || parsed.id !== id || (!hasResult && !hasError) || (hasResult && hasError)) { + settleReject(fail(`downstream HTTP response was not a well-formed JSON-RPC 2.0 response matching request id ${id}`, "GATEWAY_DOWNSTREAM_MALFORMED_RESPONSE")); + return; + } + reachable = true; // a well-formed response (success or RPC error) proves the endpoint IS reachable + /* Codex PR #29 review round 4 "branch on HTTP error-property presence": this + * previously branched on parsed.error's truthiness, so an envelope shaped like + * {"jsonrpc":"2.0","id":1,"error":null} -- which passes the exactly-one-of + * hasResult/hasError check above via hasError, since "error" is present as an + * own property even though its value is falsy -- fell through to the success + * branch and resolved parsed.result (undefined) as though the call had + * genuinely succeeded. Branch on the hasError presence flag instead, matching + * connectStdio's own fix for the identical shape just above in this file. */ + if (hasError) { + const errObj = parsed.error && typeof parsed.error === "object" && !Array.isArray(parsed.error) ? parsed.error : null; + if (!errObj) { + // hasError is a presence check, not a shape check: {"error":null} and + // {"error":"oops"} both satisfy it. Fail closed rather than crash on + // parsed.error.message (null/string has no .message) or silently attest + // a non-error value as this call's genuine failure detail. + settleReject(fail(`downstream HTTP response's "error" field for request id ${id} was present but not a JSON-RPC error object`, "GATEWAY_DOWNSTREAM_MALFORMED_RESPONSE")); + return; + } + settleReject(Object.assign(fail(errObj.message || "downstream error", "GATEWAY_DOWNSTREAM_RPC_ERROR"), { rpcError: errObj })); + } else { + settleResolve(parsed.result); + } + }); + } + ); + req.on("error", (error) => { + reachable = false; + settleReject(fail(`downstream HTTP request failed: ${error.message}`, "GATEWAY_DOWNSTREAM_DISCONNECTED")); + }); + req.setTimeout(timeoutMs || DEFAULT_REQUEST_TIMEOUT_MS, () => { + reachable = false; + req.destroy(fail("downstream HTTP request timed out", "GATEWAY_DOWNSTREAM_TIMEOUT")); + }); + req.end(body); + }); + } + + /** Plain request/response HTTP has no way to abort work already handed to the + * downstream (the request is already sent; there is no persistent connection to send a + * follow-up cancellation notification over, per this transport's own doc comment + * above). Best-effort no-op, disclosed rather than pretending to cancel. */ + function cancel() { + return false; + } + + function notify(method, params) { + /* CodeRabbit PR #29 review "send notifications/initialized without an id": call() + * unconditionally assigns a JSON-RPC `id` (nextId++), which turns every notification + * sent through it into a request instead -- a real protocol violation (JSON-RPC 2.0 / + * MCP both define a notification as exactly "no id member"), and a conforming + * downstream is free to reject it. Post the notification directly, with no id, rather + * than delegating to call(). + * + * Codex PR #29 review round 5 "await HTTP initialized delivery before listing + * tools": this used to fire-and-forget with no way for a caller to know the request + * had actually been sent, so connectAllDownstreams's own initialize -> notify + * ("notifications/initialized") -> tools/list sequence issued tools/list on a + * separate, unordered HTTP request/socket without waiting for the notification to + * even leave this process -- a strict, stateful downstream enforcing MCP's own + * "initialized before further requests" ordering could receive or process tools/list + * first and reject the handshake. Returns a Promise that resolves once this request + * SETTLES (success or failure) rather than a raw fire-and-forget void -- this stays + * best-effort in that a network failure here is never surfaced to the caller as a + * rejection (a downstream that is unreachable is not this caller's problem, matching + * call()'s own "closed" short-circuit); only the TIMING is now observable, bounded by + * the same DEFAULT_REQUEST_TIMEOUT_MS every other request in this file uses, so an + * unresponsive downstream cannot hang a caller that awaits this indefinitely. */ + if (closed) return Promise.resolve(); + const isStatelessMetaMethod = STATELESS_META_METHODS.has(method); + const effectiveParams = isStatelessMetaMethod + ? { ...(params || {}), _meta: { ...buildStatelessMeta(options), ...((params && params._meta) || {}) } } + : params; + const declaredProtocolVersion = isStatelessMetaMethod ? STATELESS_META_PROTOCOL_VERSION : MCP_PROTOCOL_VERSION; + // No "id" field: this must stay a real JSON-RPC/MCP notification (see comment above), + // unlike call()'s request body which always assigns one. + const body = JSON.stringify({ jsonrpc: "2.0", method, params: effectiveParams }); + return new Promise((resolve) => { + let settled = false; + const settle = () => { if (!settled) { settled = true; clearTimeout(timer); resolve(); } }; + /* CodeRabbit PR #29 review round 7 "destroy the request when the notification + * deadline fires": settle() only clears the timer and resolves the promise -- it + * never touches the in-flight ClientRequest/socket. A downstream that never responds + * and never errors previously left this request (and its socket) open past the + * deadline this very promise already gave up on, instead of tearing it down the way + * call()'s own timeout path does. Mark reachable=false and best-effort destroy the + * request before settling, matching call()'s cleanup on its own timeout. */ + const timer = setTimeout(() => { + reachable = false; + try { req.destroy(); } catch (error) { /* best effort */ } + settle(); + }, DEFAULT_REQUEST_TIMEOUT_MS); + if (typeof timer.unref === "function") timer.unref(); + const req = client.request(url, { + method: "POST", + // Mirrors call()'s own header construction above (kept in sync manually -- + // notify() has no id/response to correlate, so it cannot share call()'s promise body). + headers: { + "content-type": "application/json", + "content-length": Buffer.byteLength(body), + "mcp-protocol-version": declaredProtocolVersion, + "mcp-method": method, + ...(method === "tools/call" && params && typeof params.name === "string" ? { "mcp-name": params.name } : {}), + ...(options.headers || {}), + }, + }); + req.on("error", () => { reachable = false; settle(); }); // best-effort: nothing to reject, no caller treats this as a failure + req.on("response", (res) => { res.resume(); res.on("end", settle); res.on("error", settle); }); // drain and discard; notify() never reads a result + req.end(body); + }); + } + + function close() { + closed = true; + } + + return { + transport: "http", + call, + notify, + cancel, + close, + onNotification: (handler) => notificationHandlers.push(handler), + isClosed: () => closed, + isReachable: () => reachable, + whenClosed: () => Promise.resolve(null), + }; +} + +/** Connects to one configured downstream server per its `transport`. `serverConfig. + * token_ref`, when present (http only -- board decision 2026-09-04, PR #29 review "speak + * authenticated MCP over downstream HTTP"), is resolved via config.js's own + * resolveSecretRef (the same "env var name or file path, never the raw secret" + * convention signing_key_ref and agent_listen.token_ref already use) and sent as a + * standard bearer Authorization header -- this repo's own MCP HTTP server, and any + * downstream enforcing the same contract, requires one on every request. */ +function connectDownstream(serverConfig, options = {}) { + if (serverConfig.transport === "stdio") return connectStdio(serverConfig.endpoint, options); + if (serverConfig.transport === "http") { + let httpOptions = options; + if (serverConfig.token_ref) { + const token = resolveSecretRef(serverConfig.token_ref, `downstream_servers["${serverConfig.name}"].token_ref`); + httpOptions = { ...options, headers: { ...(options.headers || {}), authorization: `Bearer ${token}` } }; + } + return connectHttp(serverConfig.endpoint, httpOptions); + } + throw fail(`Unknown downstream transport "${serverConfig.transport}"`, "INVALID_ARGUMENT"); +} + +/** Connects to every configured downstream server, running the initialize + tools/list + * handshake against each (SS3.2). On any connection or handshake failure, closes every + * connection already opened and rethrows -- SS7's "downstream unreachable at startup: + * refuse to start" (the hard-refuse resolution this build makes explicitly; see the + * build report's "TRD ambiguity resolved" section for SS10 OQ-2). */ +async function connectAllDownstreams(downstreamServers, options = {}) { + const connections = new Map(); + const toolOwners = new Map(); // toolName -> serverName (first-registered wins, duplicates flagged) + const mergedTools = []; + /* Codex PR #29 review round 3 "store server information without prototype-sensitive + * keys": downstream server names are arbitrary configured strings, and a plain object + * literal treats a name of "__proto__" as a prototype assignment rather than an + * enumerable own property -- silently corrupting this object's prototype instead of + * recording that server's info, while the server's tools remain routable through the + * (Map-keyed, not affected by this) toolOwners/mergedTools structures. A null-prototype + * object accepts any string as a genuine own key. */ + const serverInfos = Object.create(null); + try { + for (const serverConfig of downstreamServers) { + let conn; + try { + /* Codex PR #29 review round 4 "preserve the originating server for sampling": the + * shared `options.onRequest` (when provided) previously reached every downstream + * connection completely unchanged, so nothing in that shared closure could tell + * which configured server actually emitted a given unsolicited request -- every + * downstream-initiated sampling call was attributed to a single placeholder + * instead of the real originating server. Bind this server's own name into its + * own connection's callback here, once, rather than have every caller of + * connectAllDownstreams re-derive it. */ + let perServerOptions = options; + if (typeof options.onRequest === "function") perServerOptions = { ...perServerOptions, onRequest: (msg) => options.onRequest(msg, serverConfig.name) }; + /* Same per-server name binding as onRequest just above, applied to the newly + * added onUnmatchedResponse callback (Codex PR #29 review "surface unmatched + * stdio responses to the session recorder") so a caller logging/attributing an + * unmatched response can name which downstream produced it. */ + if (typeof options.onUnmatchedResponse === "function") perServerOptions = { ...perServerOptions, onUnmatchedResponse: (msg) => options.onUnmatchedResponse(msg, serverConfig.name) }; + conn = connectDownstream(serverConfig, perServerOptions); + /* Full MCP initialize params (protocolVersion + capabilities, not just + * clientInfo) -- a downstream that actually validates the initialization + * contract (this repo's own MCP server included) rejects a request missing + * either. Followed by the required post-initialize "initialized" notification + * before this client is allowed to send any other request (tools/list here) -- + * skipping it left the handshake incomplete even though the permissive test + * fixture tolerated it. + * + * Codex PR #29 review round 4 "advertise sampling before accepting sampling + * requests": gateway.js only ever relays a downstream's sampling/createMessage + * request when the agent transport is stdio (the only transport that can push a + * request to the agent) -- but this call previously declared `capabilities: {}` + * unconditionally, so a conforming downstream server would never send that + * request in the first place, having negotiated that this client supports no + * optional capabilities. Advertise the sampling client capability exactly when + * the caller says the agent transport can actually relay it. */ + const initResult = await conn.call("initialize", { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: options.supportsSampling ? { sampling: {} } : {}, + clientInfo: options.clientInfo || { name: "graphsmith-standalone-gateway", version: "1.0" }, + }); + /* Codex PR #29 review "validate the downstream initialize result": a successful + * JSON-RPC envelope whose "result" is empty or missing the MCP-required + * protocolVersion/capabilities/serverInfo fields previously fell straight through + * to serverInfos[...] = null and startup continued with an unnegotiated protocol + * -- GatewayProxy then advertises `serverInfo: null` to the agent (an invalid + * initialize result a conforming client can reject) instead of failing loudly the + * same way the tools/list validation below already does for a malformed + * downstream payload. */ + if ( + !initResult || + typeof initResult !== "object" || + typeof initResult.protocolVersion !== "string" || + initResult.protocolVersion.length === 0 || + !initResult.capabilities || + typeof initResult.capabilities !== "object" || + Array.isArray(initResult.capabilities) || + !initResult.serverInfo || + typeof initResult.serverInfo !== "object" || + Array.isArray(initResult.serverInfo) || + typeof initResult.serverInfo.name !== "string" || + initResult.serverInfo.name.length === 0 + ) { + throw fail( + `downstream server "${serverConfig.name}" returned a malformed initialize result: ` + + `"protocolVersion" (non-empty string), "capabilities" (object) and "serverInfo.name" ` + + `(non-empty string) are all required by the MCP initialize handshake.`, + "GATEWAY_DOWNSTREAM_MALFORMED_RESPONSE" + ); + } + /* Codex PR #29 review round 8 "reject unsupported downstream protocol selections": + * the shape check above only proves protocolVersion is A non-empty string -- not + * that it names a version this client is actually verified compatible with. This + * client declares (in the initialize call just above) that it implements exactly + * MCP_PROTOCOL_VERSION; a downstream selecting a DIFFERENT version previously let + * startup proceed straight to "notifications/initialized" and "tools/list" using + * this gateway's hard-coded protocol semantics against a negotiation the downstream + * never actually agreed to. Distinct error code from the shape check above: this + * result is well-formed, just naming a version outside SUPPORTED_DOWNSTREAM_ + * PROTOCOL_VERSIONS, which is a different failure than a malformed/missing field. */ + if (!SUPPORTED_DOWNSTREAM_PROTOCOL_VERSIONS.has(initResult.protocolVersion)) { + throw fail( + `downstream server "${serverConfig.name}" selected protocol version ` + + `"${initResult.protocolVersion}", which this gateway does not support (supported: ` + + `${Array.from(SUPPORTED_DOWNSTREAM_PROTOCOL_VERSIONS).join(", ")}). Refusing to proceed with ` + + "an incompatible protocol negotiation.", + "GATEWAY_DOWNSTREAM_UNSUPPORTED_PROTOCOL_VERSION" + ); + } + serverInfos[serverConfig.name] = initResult.serverInfo; + /* Codex PR #29 review round 5 "await HTTP initialized delivery before listing + * tools": notify() now returns a Promise that resolves once this notification + * has actually settled (see its own header comment in connectHttp) -- awaited + * here so tools/list below is never issued before "notifications/initialized" + * has been sent, closing the ordering gap a strict, stateful HTTP downstream + * could otherwise observe. connectStdio's own notify() returns no Promise; await + * on a non-thenable resolves on the next microtask, so this line is a no-op wait + * for stdio downstreams, not a behavior change for them. */ + await conn.notify("notifications/initialized", {}); + + /* Follows tools/list's nextCursor until the downstream reports none, so a + * paginated surface is fully advertised rather than silently truncated to its + * first page. Bounded (page cap + cursor-cycle detection, AND cumulative byte + * size -- see MAX_TOTAL_TOOLS_DESCRIPTOR_BYTES above) so a downstream that never + * terminates pagination, or returns unboundedly large pages, fails this server's + * startup instead of hanging or exhausting memory. */ + const tools = []; + const seenCursors = new Set(); + let cursor; + let pages = 0; + let totalToolsBytes = 0; + do { + const toolsResult = await conn.call("tools/list", cursor !== undefined ? { cursor } : {}); + /* Codex PR #29 review "reject malformed tools/list payloads": a well-formed + * JSON-RPC envelope whose "tools" field is present but not an array (or empty) + * would previously fall through to the "[]" default -- gateway startup then + * succeeds with a silently truncated (or entirely empty) tool surface instead + * of failing loudly, unlike this same function's own tool-name-collision and + * pagination-loop guards a few lines below. Round 3 tightened this further + * ("require the tools array in every tools/list result"): the MCP spec requires + * "tools" to be present (as an array, possibly empty) on every successful + * tools/list result, but the check above only rejected a WRONG-TYPE "tools" -- + * a result missing the property entirely (e.g. `{}`) still silently fell through + * to the same "[]" default this comment already flags as the wrong behavior. */ + if (!toolsResult || typeof toolsResult !== "object" || !Object.prototype.hasOwnProperty.call(toolsResult, "tools") || !Array.isArray(toolsResult.tools)) { + throw fail( + `downstream server "${serverConfig.name}" returned a malformed tools/list result: "tools" must be ` + + `present and an array (got ${toolsResult && typeof toolsResult === "object" ? typeof toolsResult.tools : "no result object"}).`, + "GATEWAY_DOWNSTREAM_MALFORMED_RESPONSE" + ); + } + const pageTools = toolsResult.tools; + for (const tool of pageTools) { + if (!tool || typeof tool !== "object" || typeof tool.name !== "string" || tool.name.length === 0) { + throw fail( + `downstream server "${serverConfig.name}" returned a malformed tool descriptor in tools/list ` + + `(missing or invalid "name").`, + "GATEWAY_DOWNSTREAM_MALFORMED_RESPONSE" + ); + } + /* Codex PR #29 review round 5 "reject tool descriptors without a valid input + * schema": the check above validates only "name" -- a descriptor such as + * {"name":"x"} (no inputSchema at all) previously passed, was normalized to + * `schema: null` a few lines below, and was forwarded to agents as a tool + * with no input contract. The MCP Tool schema requires inputSchema on every + * tool; a client that validates the advertised contract can reject this + * gateway's otherwise successful tools/list over a single misbehaving + * downstream tool descriptor. */ + if (!tool.inputSchema || typeof tool.inputSchema !== "object" || Array.isArray(tool.inputSchema)) { + throw fail( + `downstream server "${serverConfig.name}" returned a malformed tool descriptor for ` + + `"${tool.name}" in tools/list (missing or invalid "inputSchema": must be an object).`, + "GATEWAY_DOWNSTREAM_MALFORMED_RESPONSE" + ); + } + } + tools.push(...pageTools); + totalToolsBytes += Buffer.byteLength(JSON.stringify(pageTools)); + if (totalToolsBytes > MAX_TOTAL_TOOLS_DESCRIPTOR_BYTES) { + throw fail( + `downstream server "${serverConfig.name}" tools/list surface exceeded the cumulative ` + + `${MAX_TOTAL_TOOLS_DESCRIPTOR_BYTES}-byte cap across its paginated pages`, + "GATEWAY_DOWNSTREAM_MALFORMED_RESPONSE" + ); + } + pages++; + const nextCursor = toolsResult && toolsResult.nextCursor; + if (nextCursor === undefined || nextCursor === null) { + cursor = undefined; + } else if (pages >= MAX_TOOLS_LIST_PAGES || seenCursors.has(nextCursor)) { + throw fail( + `downstream server "${serverConfig.name}" tools/list pagination did not terminate ` + + `(page cap ${MAX_TOOLS_LIST_PAGES} reached or a repeated cursor was returned)`, + "GATEWAY_DOWNSTREAM_PAGINATION_LOOP" + ); + } else { + seenCursors.add(nextCursor); + cursor = nextCursor; + } + } while (cursor !== undefined); + + for (const tool of tools) { + if (toolOwners.has(tool.name)) { + throw fail( + `Refusing to start: tool name "${tool.name}" is advertised by both downstream server ` + + `"${toolOwners.get(tool.name)}" and "${serverConfig.name}" -- configure unique tool ` + + "names across downstreams (colliding names would silently route an agent's call for " + + "the second server's tool to the first server's implementation).", + "GATEWAY_TOOL_NAME_COLLISION" + ); + } + // Preserve the tool's full descriptor (description, annotations, outputSchema, + // etc.) rather than keeping only name+schema -- GatewayProxy's tools/list + // response reads t.description, which a narrower projection would discard. + const owned = { ...tool, name: tool.name, server: serverConfig.name, schema: tool.inputSchema || tool.schema || null }; + mergedTools.push(owned); + toolOwners.set(tool.name, serverConfig.name); + } + } catch (error) { + if (conn) { try { await conn.close(); } catch (closeError) { /* best effort */ } } + if ( + error.code === "GATEWAY_TOOL_NAME_COLLISION" || + error.code === "GATEWAY_DOWNSTREAM_PAGINATION_LOOP" || + error.code === "GATEWAY_DOWNSTREAM_MALFORMED_RESPONSE" || + error.code === "GATEWAY_DOWNSTREAM_UNSUPPORTED_PROTOCOL_VERSION" + ) { + throw error; + } + throw fail( + `Refusing to start: downstream server "${serverConfig.name}" (${serverConfig.transport} ${serverConfig.endpoint}) ` + + `is unreachable or failed its handshake: ${error.message}`, + "GATEWAY_DOWNSTREAM_UNREACHABLE" + ); + } + connections.set(serverConfig.name, conn); + } + } catch (error) { + for (const conn of connections.values()) { + try { await conn.close(); } catch (closeError) { /* best effort */ } + } + throw error; + } + return { connections, mergedTools, toolOwners, serverInfos }; +} + +module.exports = { + connectStdio, + connectHttp, + connectDownstream, + connectAllDownstreams, + DEFAULT_REQUEST_TIMEOUT_MS, + STDIO_CHILD_TERMINATE_TIMEOUT_MS, + SUPPORTED_DOWNSTREAM_PROTOCOL_VERSIONS, + MAX_HTTP_RESPONSE_BYTES, + MAX_TOTAL_TOOLS_DESCRIPTOR_BYTES, + nextResidualLineBytes, + buildStdioChildEnv, +} diff --git a/scripts/gateway/gateway.js b/scripts/gateway/gateway.js new file mode 100644 index 00000000..28bcf723 --- /dev/null +++ b/scripts/gateway/gateway.js @@ -0,0 +1,928 @@ +#!/usr/bin/env node +/* GraphSmith standalone gateway -- process entry point and lifecycle (Standalone + * Gateway TRD SS3.7/SS3.8, SG-FR-1 through SG-FR-5, SG-FR-7). Ties together: + * - mode-gate.js SG-FR-1/SG-FR-2, checked FIRST, ahead of even writer-claim (SS3.8) + * - config.js SS4 config schema + * - writer-claim.js FR-1..FR-4, reused unchanged (SS3.7) + * - downstream.js SS3.1/SS3.2 + * - proxy.js SS3.3/SS6 dispatch (session capture/correlation/finalization) + * - chain.js SG-FR-5 persistence + * + * Agent-facing transport: stdio is this build's primary, fully-exercised path (one + * process, one connection, matching mcp-server/src/stdioTransport.js's own framing and + * this codebase's existing "the OS process boundary IS the trust boundary" convention + * for stdio). An HTTP agent-facing listener is also implemented (config `agent_listen. + * transport: "http"`) for the "multiple concurrent agents" scenario (SS8 test 3) -- + * sessions are keyed by an explicit, server-minted `Mcp-Session-Id` (board decision + * 2026-09-08; see scripts/gateway/agent-transport.js#runHttpAgentTransport's own header + * comment), not by TCP socket, so multiple logical sessions can even share one + * connection-pooling backend socket without colliding. + * + * Zero-dependency, Node >= 18. + */ +"use strict"; + +const path = require("path"); +const crypto = require("crypto"); +const fs = require("fs"); + +const modeGate = require("./mode-gate.js"); +const gatewayConfig = require("./config.js"); +const chain = require("./chain.js"); +const session = require("./session.js"); +const { GatewayProxy, MAX_PENDING_CALLS_PER_SESSION, MAX_COMPLETED_CALLS_PER_SESSION } = require("./proxy.js"); +const downstream = require("./downstream.js"); +const { runStdioAgentTransport, runHttpAgentTransport } = require("./agent-transport.js"); +const writerClaimModule = require("../writer-claim.js"); +const { WriterClaim } = writerClaimModule; +const registerGatewaySessions = require("../../checks/register-gateway-sessions.js"); +const stateStore = require("../state-store.js"); + +/** SS3.7's bounded drain: waits (polling) until every open session on `proxy` has no + * calls still in flight, or `timeoutMs` elapses, whichever first. Extracted as its own + * function so it is unit-testable directly against a GatewayProxy + fake slow downstream + * connection, without needing a real OS SIGTERM (which Windows cannot deliver for + * graceful in-process handling -- see tests/gateway/e2e's own header comment). Returns + * true if drained cleanly, false if the timeout was hit with calls still pending. */ +async function drainOpenSessions(proxy, timeoutMs, pollMs = 25) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const stillInFlight = Array.from(proxy.sessions.values()).some((s) => s.pendingCalls.size > 0); + if (!stillInFlight) return true; + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + return !Array.from(proxy.sessions.values()).some((s) => s.pendingCalls.size > 0); +} + +/* Codex PR #29 review round 3 "preserve the configured shutdown deadline for HTTP + * calls": Node's http.Server#close() waits for every still-active response to finish + * before its callback fires -- a request still awaiting a slow (up to + * DEFAULT_REQUEST_TIMEOUT_MS, 30s) downstream call can hold shutdown well past the + * nominal drainTimeoutMs (default 5s) drain cap above. This bounds how long doStop() + * will wait for the listener to close cleanly before force-terminating any sockets + * still open on it, so a slow HTTP call can no longer extend shutdown indefinitely. */ +const HTTP_LISTENER_CLOSE_TIMEOUT_MS = 2000; + +/* Codex PR #29 review round 8 "install shutdown handlers before gateway startup + * completes": before this, main() only registers SIGTERM/SIGINT handlers AFTER + * startGateway() resolves -- a termination signal received while a slow downstream + * handshake is still in progress fell through to Node's default immediate-exit behavior, + * skipping cleanup of an already-acquired writer-claim and any already-spawned stdio + * downstream children (leaking both: a stale claim blocking the next start, and orphaned + * child processes). The 5-model external panel's majority position (Paul-approved) was + * explicitly AGAINST building a signal handler that tries to safely tear down + * partially-initialized startup state -- that is a real correctness risk on the + * WriterClaim lock-safety path, worse than the status quo. This bounds the slow step + * itself instead: if the downstream-connection phase (connectAllDownstreams below -- + * empirically the one startup step with no bound of its own, unlike every downstream RPC + * inside it, which already times out via DEFAULT_REQUEST_TIMEOUT_MS) hasn't completed + * within this deadline, it is routed through the SAME startup-failure path an outright + * connection failure already uses (writerClaim.release() + rethrow) rather than left to + * hang indefinitely waiting for a signal handler that isn't installed yet. Comfortably + * exceeds DEFAULT_REQUEST_TIMEOUT_MS (downstream.js, 30s) -- a downstream pagination + * handshake can legitimately need more than one such round trip -- while still being a + * fixed, non-speculative bound (same discipline as every other *_TIMEOUT_MS constant in + * this codebase), not an unbounded wait. Overridable via options for tests that need a + * short deadline to run fast (mirrors drainTimeoutMs/statusWriteIntervalMs's own + * options-override pattern below). */ +const STARTUP_DOWNSTREAM_CONNECT_TIMEOUT_MS = 60000; + +function fail(message, code = "GATEWAY_ERROR") { + const error = new Error(message); + error.code = code; + return error; +} + +/** Races `promise` against a bounded timeout, rejecting with `makeError()` if the timeout + * elapses first. `promise` itself is NOT cancelled on timeout (there is no cancellation + * primitive for an in-flight downstream handshake) -- see startGateway's own call site for + * how it best-effort cleans up a connect that finishes late, after this has already given + * up on it. */ +function withTimeout(promise, timeoutMs, makeError) { + return new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + reject(makeError()); + }, timeoutMs); + if (typeof timer.unref === "function") timer.unref(); + promise.then( + (value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }, + (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + } + ); + }); +} + +/** Answers a downstream server's own unsolicited request (board decision 2026-09-04, PR + * #29 review "forward downstream sampling requests upstream"). Only `sampling/ + * createMessage` is recognized -- the one MCP-defined case of a server asking the + * client's model to do inference. Only forwarded when `agentPusher.current` is set, + * which gateway.js's own startGateway() only ever does for the stdio agent transport + * (the only one that can push a request to the agent rather than merely reply to one -- + * see agent-transport.js's header). Any other case (an http agent transport, or no agent + * currently connected) gets a real JSON-RPC error naming exactly why, rather than the + * silent drop this was before. */ +function forwardDownstreamRequestToAgent(msg, agentPusher, log, proxy, serverName) { + if (msg.method !== "sampling/createMessage") { + return Promise.resolve({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: `This gateway does not forward downstream-initiated method "${msg.method}" to the agent.` } }); + } + if (!agentPusher.current) { + return Promise.resolve({ + jsonrpc: "2.0", + id: msg.id, + error: { + code: -32000, + message: + 'Forwarding a downstream server\'s "sampling/createMessage" request to the agent is only ' + + "supported when this gateway's agent_listen.transport is \"stdio\" (stdio is the only agent " + + "transport that can push a request to the agent rather than only reply to one it sent). This " + + "gateway is not currently configured for stdio agent transport, or no agent is connected.", + }, + }); + } + /* Codex PR #29 review "record downstream-initiated sampling in the session": this + * forward previously ran entirely outside session.js's bookkeeping -- the sealed + * bundle for the connection that actually observed and relayed this model invocation + * never recorded it (model_call:true and its hashed input/result), even though the + * gateway genuinely handled it. An attestation gap, not just a missing log line. + * agentPusher.connectionId unambiguously names the one session this belongs to: this + * branch is only ever reachable when agentPusher.current is set, which gateway.js only + * does for the stdio agent transport (see this function's own doc above), and stdio is + * a strict one-process-one-connection transport (agent-transport.js's own header). */ + const s = proxy && agentPusher.connectionId ? proxy.sessions.get(agentPusher.connectionId) : null; + const correlationKey = s ? Symbol("downstream-initiated-sample") : null; + const startTs = proxy ? proxy.now() : Date.now(); + /* Codex PR #29 review round 4 "preserve the originating server for sampling": with + * multiple stdio downstreams configured, every connection's onRequest callback used to + * be this exact same function reference, so nothing here could tell which downstream + * server actually emitted the request -- every sampling step was recorded and logged + * under the placeholder server name "sampling" regardless of which real downstream + * initiated it. `serverName` is now bound per-connection by connectAllDownstreams (see + * downstream.js), so fall back to the old placeholder only for a caller that predates + * this parameter (there is none in this codebase, but this keeps the function honest + * about its own default rather than crashing on a missing argument). */ + const recordedServerName = serverName || "sampling"; + /* Codex PR #29 review round 6 "bound downstream-pushed sampling calls": this path + * records and forwards every downstream-initiated sampling request unconditionally, + * unlike GatewayProxy#handleMessage's own agent-initiated dispatch (proxy.js), which + * refuses to admit another concurrent call once a session already has + * MAX_PENDING_CALLS_PER_SESSION genuinely pending. Without the same admission bound + * here, a faulty or compromised sampling-capable stdio downstream could still exhaust + * memory via this separate route despite that cap. Mirrors proxy.js's own error shape. */ + if (s && s.pendingCalls.size >= MAX_PENDING_CALLS_PER_SESSION) { + return Promise.resolve({ + jsonrpc: "2.0", + id: msg.id, + error: { + code: -32000, + message: `This session already has ${MAX_PENDING_CALLS_PER_SESSION} call(s) pending -- refusing to admit another concurrent downstream-initiated sampling call until at least one resolves.`, + }, + }); + } + /* Codex PR #29 review round 8 "cap completed downstream sampling history": proxy.js's + * own agent-initiated dispatch (GatewayProxy#handleMessage) additionally enforces + * MAX_COMPLETED_CALLS_PER_SESSION -- bounding session.calls itself, not just calls + * genuinely in flight -- but this separate downstream-initiated sampling path never + * consulted it. A sampling-capable stdio downstream that issues requests SEQUENTIALLY + * (each response removing the prior request from pendingCalls before the next starts) + * never trips the pending-call check above, so this path could append to session.calls + * without bound over an indefinitely-connected downstream's lifetime, exhausting memory. + * Mirrors proxy.js's own admission-refusal shape exactly (same code, same message + * template, same s.calls.length + s.pendingCalls.size admission math as that file's own + * post-round-8 fix) so both paths present one consistent contract to whatever is on the + * other end of a session. */ + if (s && s.calls.length + s.pendingCalls.size >= MAX_COMPLETED_CALLS_PER_SESSION) { + return Promise.resolve({ + jsonrpc: "2.0", + id: msg.id, + error: { + code: -32000, + message: `This session has already completed ${MAX_COMPLETED_CALLS_PER_SESSION} call(s) -- refusing to admit another call on this connection; start a new session.`, + }, + }); + } + if (s) { + session.recordCallStart(s, correlationKey, { + tool: "sampling/createMessage", + server: recordedServerName, + arguments: msg.params, + isModelCall: true, + ts: startTs, + }); + } + /* Codex PR #29 review round 3 "log completed downstream-initiated sampling steps": + * proxy.js's own agent-initiated call path (GatewayProxy#handleMessage) emits a + * structured "gateway_call_completed" log line for every call it records -- this + * separate downstream-initiated sampling path records the same kind of execution-trace + * step (model_call:true) but previously emitted no matching completion log on success, + * and its failure log above lacked the connection id, step, status, and duration every + * other completed-call log line carries. Mirror that same structured shape here so a + * session containing a downstream-initiated sampling call has a complete, consistent + * operational log regardless of which side (agent or downstream) initiated the call. */ + /* Codex PR #29 re-triage "use the non-throwing logger for sampling completions": + * this fires AFTER the agent's model result has already been recorded (recordCallResult + * above, in both the success and failure branches below) -- exactly the class of call + * site GatewayProxy#safeLog's own doc comment (proxy.js) says must never call a + * caller-supplied `log` directly, since a throwing logger here would reject this + * function's returned promise and send the downstream an internal-error response for a + * model invocation that in fact already completed, inviting a retry that could + * duplicate an externally consequential effect. Wrapped inline (rather than routed + * through proxy.safeLog) because this function's own tests construct a minimal + * `proxy`-like object ({sessions, now}) that does not carry GatewayProxy's full + * method set -- the try/catch below gives the exact same non-throwing guarantee as + * safeLog's own body without assuming `proxy` is a real GatewayProxy instance. */ + function logCompletion(isError) { + const completedAt = proxy ? proxy.now() : Date.now(); + const recordedCall = s ? s.calls[s.calls.length - 1] : null; + try { + log(JSON.stringify({ + event: "gateway_call_completed", + connection_id: agentPusher.connectionId || null, + step: recordedCall ? recordedCall.seq : null, + tool: "sampling/createMessage", + server: recordedServerName, + status: isError ? "error" : "ok", + duration_ms: completedAt - startTs, + })); + } catch (error) { + // Deliberately swallowed -- see comment above: a failing logger must never turn an + // already-completed sampling call into a rejected forward/error response. + } + } + return agentPusher.current(msg.method, msg.params).then( + (result) => { + /* Mirrors proxy.js's own "correlatedNow" guard (CodeRabbit PR #29 review, round 1): + * the session can finalize (agent disconnects) while this forwarded request is + * still in flight awaiting the agent's model. Only record if it's still genuinely + * pending, so this never throws SESSION_FINALIZED or logs a spurious anomaly for an + * entry the gateway itself already removed. */ + const correlatedNow = s && !s.finalized && s.pendingCalls.has(correlationKey); + if (correlatedNow) { + session.recordCallResult(s, correlationKey, { result, isError: false, ts: proxy.now() }); + logCompletion(false); + } + return { jsonrpc: "2.0", id: msg.id, result }; + }, + (error) => { + const correlatedNow = s && !s.finalized && s.pendingCalls.has(correlationKey); + /* Codex PR #29 review round 4 "emit only one log line for sampling failures": + * this used to unconditionally log the plain diagnostic below AND, when + * correlatedNow, also call logCompletion(true) -- double-logging the same failed + * step and violating AGENTS.md's "one log line per step" contract. The structured + * completion record already carries the failure (status:"error"); only fall back to + * the plain diagnostic when there is no session to record a structured line against + * in the first place, so every failure still gets exactly one log line either way. + * + * CodeRabbit PR #29 review round 4 "use the fallback only when no session exists": + * `correlatedNow` also goes false once a stdio agent disconnect has already run this + * call through closeConnection/handleDownstreamDisconnect's own pending-call + * cleanup (session.markPendingAsDisconnected) -- and that cleanup already emitted + * this exact step's structured gateway_call_completed/status:"disconnected" log via + * its onDisconnect callback (see proxy.js#logDisconnectedCall). `s` is still truthy + * in that case (the session object itself isn't gone, just this call's pending + * entry), so the old unconditional `else` fired the plain diagnostic below on top of + * that already-emitted completion log -- two log lines for one step. Only fall back + * to the plain diagnostic when there was never a session to correlate against at + * all, so a disconnect-during-forward gets exactly the one completion log + * closeConnection/handleDownstreamDisconnect already recorded, not a second one. */ + if (correlatedNow) { + session.recordCallResult(s, correlationKey, { result: { error: error.message }, isError: true, ts: proxy.now() }); + logCompletion(true); + } else if (!s) { + log(`downstream sampling/createMessage forward to agent failed: ${error.message}`); + } + return { jsonrpc: "2.0", id: msg.id, error: { code: -32000, message: error.message } }; + } + ); +} + +/** Codex PR #29 review "surface unmatched stdio responses to the session recorder": a + * stdio downstream response whose id has no live pending call (already timed out, or a + * downstream fabricating/replaying an id this gateway never sent) previously vanished + * silently at the transport layer (downstream.js), even though that file's own doc + * comment already promised the session-correlation layer records it as an anomaly -- + * nothing actually wired the two together. Mirrors forwardDownstreamRequestToAgent's own + * attribution rule just above: a downstream connection is shared by every currently open + * agent session, so this can only be honestly attributed to ONE session's sealed audit + * trail when this gateway's agent transport is stdio (the one transport that can only + * ever have a single open session -- agentPusher.connectionId names it; see that + * function's own header for why). Any other case (HTTP agent transport with zero or + * multiple concurrent sessions, or no agent currently connected) still surfaces the + * observed protocol violation, just as a plain operational log line naming the + * originating server -- recording it against an arbitrarily-chosen session's audit trail + * would misattribute a violation this gateway cannot actually pin on that session. */ +function recordUnmatchedDownstreamResponse(msg, agentPusher, log, proxy, serverName) { + const s = proxy && agentPusher.connectionId ? proxy.sessions.get(agentPusher.connectionId) : null; + if (s && !s.finalized) { + /* CodeRabbit PR #29 review round 8 "record unmatched downstream responses without + * agent-call correlation" / Codex PR #29 review round 8 "keep unmatched downstream IDs + * out of agent correlation": msg.id here is the DOWNSTREAM leg's own internal id + * (downstream.js's own numbering), not an agent-facing JSON-RPC id -- session. + * pendingCalls (which recordCallResult looks up by id) is keyed by the latter. Calling + * recordCallResult(s, msg.id, ...) risked colliding with an unrelated LIVE agent call + * that happens to share the same id value: a late response for a timed-out downstream + * id "1" arriving while a different live agent request also has id "1" would be + * (mis)treated as correlated, deleting that still-live pending call from pendingCalls + * and sealing this stale/foreign response as its result -- corrupting the session trace + * and losing the UNMATCHED_RESPONSE anomaly entirely. Use the dedicated helper, which + * only ever appends the anomaly and never touches pendingCalls. */ + session.recordUnmatchedResponseAnomaly( + s, + msg.id, + "response arrived on a downstream connection with an id that does not correlate to any agent-facing pending call", + proxy.now() + ); + return; + } + log(JSON.stringify({ event: "gateway_unmatched_downstream_response", server: serverName || null, id: msg.id })); +} + +function loadSigningKeys(config) { + const material = gatewayConfig.resolveSecretRef(config.signing_key_ref, "signing_key_ref"); + /* The signing_key_ref convention (SS4) is "a reference ... never the raw key + * material" -- resolved to a PEM-encoded ed25519 private key by this build (the same + * algorithm gsa-mcp-shim.js's own selftest and gsa-produce.js's other callers use). A + * deployment that has not yet provisioned one can point signing_key_ref at any file/ + * env var; if it isn't a valid PEM this throws a clear, named error rather than + * silently sealing unsigned/garbage bundles. */ + let privateKey; + try { + privateKey = crypto.createPrivateKey(material); + } catch (error) { + throw fail(`signing_key_ref did not resolve to a valid private key: ${error.message}`, "GATEWAY_BAD_SIGNING_KEY"); + } + /* createPrivateKey() happily accepts an RSA or EC key too -- Node can sign with either + * -- but this build unconditionally labels the key "ed25519" regardless of what was + * actually loaded. gsa-verify.js#44-49 rejects a declared-algorithm/key-type mismatch, + * so every bundle sealed with a non-ed25519 key would persist successfully and then be + * unverifiable. Check the real key type before startup completes rather than let that + * surface only much later, at verify time. */ + if (privateKey.asymmetricKeyType !== "ed25519") { + throw fail( + `signing_key_ref resolved to a ${privateKey.asymmetricKeyType || "unknown"} key, but this gateway ` + + 'only signs with ed25519 (gsa-verify.js requires the declared algorithm to match the actual key ' + + "type). Provide an ed25519 private key.", + "GATEWAY_BAD_SIGNING_KEY" + ); + } + return { privateKey, signer: config.host_id || "graphsmith-standalone-gateway", algo: "ed25519" }; +} + +/** SS3.8: the very first check, ahead of even writer-claim acquisition. Returns + * { dormant: true } if attach mode is active (not an error -- SS3.8: "log that + * standalone is dormant and exit 0"). Throws (fail-closed) on every other SS7 condition. */ +function checkModeGate(root, log) { + try { + const record = modeGate.readGatewayMode(root, { expectedMode: "standalone" }); + log(`mode-selection: standalone confirmed by "${record.confirmation.confirmed_by}" at ${new Date(record.confirmation.confirmed_at).toISOString()}`); + return { dormant: false, record }; + } catch (error) { + if (error.code === "GATEWAY_MODE_WRONG_BINARY") { + log(`mode-selection: attach mode is active -- standalone gateway is dormant. ${error.message}`); + return { dormant: true }; + } + throw error; + } +} + +/** Health/status surface (SG-NFR-3): connected downstream servers and their + * reachability, active session count, current chain seq/tail hash, time since last + * persisted bundle, and time since the chain tail was last pushed to the remote anchor + * (always "not implemented" in this build -- see chain.js#pushChainTailToRemoteAnchor). */ +function buildHealthStatus(ctx) { + const stateDir = ctx.config.state_dir; + /* Codex PR #29 review "return a health report when HEAD is corrupt": chain.readHead + * fails closed BY DESIGN (see chain.js's own header) on an unreadable or malformed + * HEAD.json -- but that throw was previously unguarded here, so the one moment an + * operator most needs this health surface to keep responding (chain corruption) was + * exactly the moment it threw instead, taking the whole status/health endpoint down. */ + let head = null; + let headError = null; + try { + head = chain.readHead(stateDir); + } catch (error) { + headError = error.message; + } + + // Time since the last persisted bundle (SG-NFR-3's stated contract): HEAD.json is + // written last in chain.appendSession's write order, so its own mtime is exactly that. + let lastPersistedAt = null; + try { + lastPersistedAt = fs.statSync(chain.headPath(stateDir)).mtime.toISOString(); + } catch (error) { + /* ENOENT (nothing persisted yet) or any other stat failure: report null rather than + * let an operator mistake a missing timestamp for "just persisted". */ + } + + /* SG-FR-7's session-chain integrity walk (checks/register-gateway-sessions.js) existed + * only as a synthetic --selftest before this: no production code ever actually ran it + * against this deployment's real on-disk chain. Wiring it into the health surface is a + * real operational path an operator or monitor can act on -- a non-"verified" result + * here is a fail-closed, visible signal (tampering, a sequence gap, or an incomplete + * append), not silently invisible until someone thinks to run --selftest by hand. */ + let sessionChainIntegrity; + if (headError) { + // HEAD.json is already known corrupt/unreadable -- don't bother attempting the walk + // (which needs `head`) just to rediscover the same failure less clearly. + sessionChainIntegrity = { status: "failed", evidence: [], assumptions: [], failure_domain: "trusted-core", reason: `HEAD.json is corrupt or unreadable: ${headError}` }; + } else { + try { + sessionChainIntegrity = registerGatewaySessions.run({ + chain: chain.readChain(stateDir), + head, + computeEntrySha256: chain.computeEntrySha256, + bundleExists: (bundleId) => fs.existsSync(chain.bundlePath(stateDir, bundleId)), + }); + } catch (error) { + sessionChainIntegrity = { status: "failed", evidence: [], assumptions: [], failure_domain: "trusted-core", reason: `session-chain verification threw: ${error.message}` }; + } + } + + return { + schema_version: "1.0", + writer_claim: ctx.writerClaim.status(), + downstream_servers: Array.from(ctx.connections.keys()).map((name) => { + const conn = ctx.connections.get(name); + return { name, reachable: typeof conn.isReachable === "function" ? conn.isReachable() : !conn.isClosed() }; + }), + active_sessions: ctx.proxy.openSessionCount(), + chain: head + ? { seq: head.seq, entry_sha256: head.entry_sha256, last_persisted_at: lastPersistedAt } + : { seq: 0, entry_sha256: null, last_persisted_at: lastPersistedAt, ...(headError ? { error: headError } : {}) }, + session_chain_integrity: sessionChainIntegrity, + remote_anchor: { implemented: false, reason: "SG-FR-6 not implemented in this build -- see chain.js#pushChainTailToRemoteAnchor" }, + }; +} + +/* Cluster D: operational exposure for buildHealthStatus(ctx) above. This is a + * single-tenant, locally-run process (not a hosted service with a real ops network + * surface) -- SG-NFR-3's health/status report existed only as `.status()` on the + * in-process handle before this, reachable from a unit test or an embedder that already + * holds the handle, but from nothing an operator could actually run against a gateway + * they only know how to reach by its config file (a separate `status` invocation, a cron + * job, a shell one-liner). A separate ops HTTP port is deliberately out of scope for this + * fix (it would need its own auth/bind-address story, mirroring agent_listen's own, + * before it could ship responsibly) -- a periodically-written status FILE plus a `status` + * CLI subcommand that reads it is the minimal, honest version of "operationally exposed" + * for a process that already writes durable state to `state_dir` for other reasons. */ +const STATUS_FILE_NAME = "gateway-status.json"; +/* Fixed, non-speculative default -- same discipline as SESSION_IDLE_TIMEOUT_MS/ + * MAX_HTTP_SESSIONS in agent-transport.js and the MAX_* constants in downstream.js/ + * proxy.js: make it configurable once a real deployment needs a different cadence, not + * before. 10s keeps `gateway.js status` usefully fresh without meaningfully adding to + * this process's I/O -- one small JSON write, not on any request's critical path. */ +const STATUS_WRITE_INTERVAL_MS = 10000; + +function gatewayStatusPath(stateDir) { + return path.join(stateDir, STATUS_FILE_NAME); +} + +/* Best-effort, non-throwing by design: a failure to write the status file (e.g. a + * momentarily full disk) is an operational inconvenience for whoever next runs `status`, + * never a reason to disrupt request handling or bring down the gateway process itself -- + * this is purely an observability side channel, not part of SG-FR-5's persisted-session + * write path. Reuses state-store.js's own atomic-write primitive (temp file + fsync + + * rename), same as chain.js#appendSession's HEAD.json write, so a reader (the `status` + * subcommand, or an operator's own tool) can never observe a half-written file. */ +function writeStatusFile(ctx, log) { + try { + const stateDir = ctx.config.state_dir; + fs.mkdirSync(stateDir, { recursive: true }); + const status = { ...buildHealthStatus(ctx), written_at: new Date().toISOString() }; + stateStore.atomicOverwriteFile(gatewayStatusPath(stateDir), JSON.stringify(status, null, 2), stateDir); + } catch (error) { + log(`failed to write status file (non-fatal): ${error.message}`); + } +} + +/** + * Starts the standalone gateway process. Returns { dormant: true } if attach mode is + * active (caller should exit 0). Otherwise returns a running gateway handle with + * `.stop()` for graceful shutdown (SIGTERM/SIGINT, SS3.7) and `.status()` (SG-NFR-3). + */ +async function startGateway(options) { + const log = options.log || ((...args) => console.error("[graphsmith-gateway]", ...args)); + + /* Cluster E (partial fix -- see PR description for what is deliberately NOT included + * here): the mode-gate validation root must track whichever project's config this + * invocation is actually loading, not this process's cwd. `gateway.js --config + * /path/to/project-b/gateway.json` run with cwd `/path/to/project-a/` previously + * validated project A's /.graphsmith/gateway-mode.json (checkModeGate's `root` + * defaulted to process.cwd()) while loadConfig() below loaded project B's config + * entirely independently -- two different projects' state read through one mode-gate + * check that named neither of them. Deriving `root` from configPath's own directory + * instead makes "which mode-selection record gates this run" track "which config this + * run loads" by construction: whatever project configPath points into is the project + * whose .graphsmith/ this checks, for every caller (the CLI's own configPath resolution + * below, and any direct startGateway() caller), not only the common case where cwd and + * the config's directory happen to coincide. options.root remains available to + * override this explicitly for a caller that genuinely keeps its config file outside + * the project root it means to validate against -- it is no longer the default source + * of truth. */ + const configPath = options.configPath || path.join(process.cwd(), "gateway-config.json"); + const root = options.root || path.dirname(path.resolve(configPath)); + + const modeResult = checkModeGate(root, log); + if (modeResult.dormant) return { dormant: true }; + + const config = gatewayConfig.loadConfig(configPath); + if (config.session_boundary === "time_window") { + throw fail( + "session_boundary=\"time_window\" is accepted by the config schema as a forward-compatible " + + "placeholder but is NOT IMPLEMENTED by this build (see scripts/gateway/session.js's header). " + + "Use \"connection\" (the default) or omit the field.", + "NOT_IMPLEMENTED" + ); + } + + const keys = loadSigningKeys(config); + + const writerClaim = new WriterClaim(config.state_dir, { hostId: config.host_id }); + writerClaim.acquire(); // FR-1: throws and this process must exit non-zero on refusal + log(`writer-claim acquired: instance ${writerClaim.instanceId} on host ${writerClaim.hostId}`); + + let proxy; + writerClaim.onClaimLost = (error) => { + log(`writer-claim lost: ${error.message} -- halting: no new sessions will be accepted.`); + if (proxy) proxy.stopAcceptingNewSessions(); + // SS7: "In-flight sessions at the moment of loss should still attempt to finalize + // and persist" -- already-open sessions are left alone here; only new admission + // stops. That is deliberately NOT the whole story: a session already open when the + // claim is lost is still allowed to run to completion and finalize, which is exactly + // where the single-writer invariant chain.appendSession depends on could be violated + // by a replacement writer that has since taken over this state directory (Cluster C). + // GatewayProxy's own isWriterClaimValid check (wired below, consulted synchronously + // right before chain.appendSession in closeConnection) is what actually closes that + // gap -- not by halting every in-flight session the instant claim loss is first + // detected, but by re-checking liveness at the one write that matters. + }; + writerClaim.startHeartbeat(); + + /* Mutable box, not a plain variable: connectAllDownstreams() below runs (and each + * downstream stdio connection's onRequest closure over it is created) BEFORE the + * agent transport is started further down, so at closure-creation time there is + * nothing to push to yet. `.current` is set once the stdio transport actually starts + * (never, for the http transport -- see forwardDownstreamRequestToAgent's own doc). */ + const agentPusher = { current: null, connectionId: null }; + + /* Codex PR #29 review round 4 "advertise sampling before accepting sampling requests": + * forwardDownstreamRequestToAgent only relays sampling/createMessage when the agent + * transport is stdio (the one transport that can push a request to the agent -- see its + * own doc above), but every downstream initialize previously declared `capabilities: {}` + * regardless. A conforming MCP server never sends a request the client hasn't declared + * support for, so a real downstream would never exercise this relay at all -- only the + * test fixture worked, because it ignores capability negotiation. `config.agent_listen` + * is already loaded above, so this is knowable before connectAllDownstreams runs. */ + const agentTransportSupportsSampling = (config.agent_listen || { transport: "stdio" }).transport !== "http"; + + /* Codex PR #29 review "keep gateway secrets out of downstream subprocess environments": + * every configured stdio downstream is spawned as a child process that, absent an + * explicit `env`, inherits this gateway's complete process.env -- including whichever + * env vars signing_key_ref / agent_listen.token_ref / a downstream's own token_ref + * resolve secrets from. Collect just those NAMES (never the resolved secret values, + * which this gateway process never needs to hand back to itself) once, here, so + * connectStdio (via connectAllDownstreams/connectDownstream) can strip them from every + * stdio child's environment regardless of which downstream is spawned. */ + const gatewaySecretEnvNames = new Set( + [config.signing_key_ref, (config.agent_listen || {}).token_ref, ...(config.downstream_servers || []).map((s) => s.token_ref)].filter( + (name) => typeof name === "string" && name.length > 0 + ) + ); + + let downstreamHandles; + const startupDownstreamConnectTimeoutMs = options.startupDownstreamConnectTimeoutMs || STARTUP_DOWNSTREAM_CONNECT_TIMEOUT_MS; + /* Codex PR #29 review round 8 "install shutdown handlers before gateway startup + * completes": kept as its own variable (not inlined into the try below) so the + * watchdog-timeout catch path can still reach the real connect promise to best-effort + * close whatever it eventually produces -- see that catch block's own comment. */ + const connectAllDownstreamsPromise = downstream.connectAllDownstreams(config.downstream_servers, { + clientInfo: { name: "graphsmith-standalone-gateway", version: "1.0" }, + supportsSampling: agentTransportSupportsSampling, + secretEnvNames: gatewaySecretEnvNames, + /* Codex PR #29 review round 4 "preserve the originating server for sampling": with + * multiple stdio downstreams, connectAllDownstreams binds each connection's own + * onRequest to its configured server name (see downstream.js) -- forward it through + * so the recorded/logged step is attributed to the real downstream, not a single + * shared placeholder. */ + onRequest: (msg, serverName) => forwardDownstreamRequestToAgent(msg, agentPusher, log, proxy, serverName), + onUnmatchedResponse: (msg, serverName) => recordUnmatchedDownstreamResponse(msg, agentPusher, log, proxy, serverName), + }); + try { + downstreamHandles = await withTimeout( + connectAllDownstreamsPromise, + startupDownstreamConnectTimeoutMs, + () => + fail( + `downstream connection phase did not complete within ${startupDownstreamConnectTimeoutMs}ms -- ` + + "treating startup as failed (a downstream handshake that hangs this long, ignoring " + + "DEFAULT_REQUEST_TIMEOUT_MS on every individual RPC inside it, is not making progress).", + "GATEWAY_STARTUP_TIMEOUT" + ) + ); + } catch (error) { + writerClaim.release(); + /* If connectAllDownstreams eventually settles AFTER this watchdog has already given up + * (and already released the writer-claim above), don't leak whatever downstream child + * processes/sockets it produced -- best-effort close them once they show up. This is + * deliberately NOT an attempt to safely tear down partially-initialized startup state + * in general (the panel's own explicitly-rejected approach, see this constant's own + * header comment) -- it only cleans up the one promise this function itself started. */ + connectAllDownstreamsPromise + .then((handles) => { + for (const conn of handles.connections.values()) { + try { conn.close(); } catch (closeError) { /* best effort */ } + } + }) + .catch(() => {}); + throw error; // SS7: downstream unreachable (or timed out) at startup -> refuse to start (hard-refuse resolution) + } + + proxy = new GatewayProxy({ + connections: downstreamHandles.connections, + mergedTools: downstreamHandles.mergedTools, + toolOwners: downstreamHandles.toolOwners, + serverInfos: downstreamHandles.serverInfos, + keys, + stateDir: config.state_dir, + log, + /* Codex PR #29 review "treat sampling as a negotiated client capability" (comment + * 4000335132): sampling/createMessage is a distinct MCP capability negotiated at + * initialize, not a tools/call -- agentTransportSupportsSampling (computed above + * from config.agent_listen.transport, the same fact this gateway already uses to + * decide whether to advertise the sampling capability to each downstream server + * and whether forwardDownstreamRequestToAgent can relay a call at all) is threaded + * into every session opened on this proxy so sealBoundaryBundle can honestly grant + * a model_call by whether sampling was actually negotiated for the connection, + * instead of checking it against the downstream tools/list surface. */ + agentSupportsSampling: agentTransportSupportsSampling, + onSessionFinalized: (connectionId, entry) => log(`session ${connectionId} finalized: chain seq ${entry.seq}, bundle ${entry.bundle_id}`), + onSealFailure: (session, error) => log(`SEAL FAILURE for connection ${session.connectionId}: ${error.message} -- session state:`, JSON.stringify({ calls: session.calls.length, pendingCalls: session.pendingCalls.size, quarantinedTo: error.quarantinedTo || null })), + // Cluster C: read fresh from disk (WriterClaim#status's own contract) rather than + // trust writerClaim's in-memory _claimToken -- a claim lost out-of-band (the file + // removed or overwritten by a replacement writer underneath this process) must be + // caught here even if this instance's own heartbeat hasn't yet noticed and fired + // onClaimLost. + isWriterClaimValid: () => writerClaim.status().held_by_this_instance, + // Re-triage fix: renew the writer-claim synchronously immediately before + // chain.appendSession in closeConnection (see GatewayProxy#renewWriterClaim's own + // doc comment) -- closes the gap where a long synchronous append blocks the + // setInterval heartbeat past staleAfterMs even though isWriterClaimValid above just + // read a fresh, still-valid claim a moment earlier. + renewWriterClaim: () => writerClaim.renew(), + }); + + for (const [name, conn] of downstreamHandles.connections.entries()) { + if (conn.whenClosed) { + conn.whenClosed().then(() => proxy.handleDownstreamDisconnect(name)).catch(() => {}); + } + } + + const listenConfig = config.agent_listen || { transport: "stdio" }; + let stdioHandle = null; + let httpHandle = null; + try { + if (listenConfig.transport === "http") { + const token = gatewayConfig.resolveSecretRef(listenConfig.token_ref, "agent_listen.token_ref"); + httpHandle = await runHttpAgentTransport({ proxy }, listenConfig, token); + log(`agent-facing HTTP listener on port ${httpHandle.port}`); + } else { + stdioHandle = runStdioAgentTransport({ proxy }); + // See forwardDownstreamRequestToAgent's doc above: only the stdio transport can + // push a request to the agent, so this is the one branch that ever populates it. + agentPusher.current = stdioHandle.pushRequest; + agentPusher.connectionId = stdioHandle.connectionId; + /* stdio is a one-process-per-connection transport (mirrors mcp-server/src/ + * stdioTransport.js's own "stdin closed -> exit cleanly" convention): once the + * agent disconnects, the connection's session is already finalized (inside + * runStdioAgentTransport's own rl "close" handler, which resolves this promise + * AFTER closeConnection completes) -- there is nothing left for this process to do + * but release the claim and exit. Without this, the process would sit idle forever + * (the writer-claim heartbeat timer and the still-open downstream child keep the + * event loop alive), never actually stopping. */ + stdioHandle.closed.then(() => stop("agent stdio disconnected").then(() => process.exit(0))); + } + } catch (error) { + /* Everything up to here (writer-claim, heartbeat, downstream connections) already + * succeeded by the time agent-listener setup can fail (an unresolved token_ref, or a + * bind failure) -- without this cleanup, that already-acquired state (in particular + * the writer-claim and any still-alive stdio downstream child) would leak: the + * process could remain running after setting a non-zero exitCode, block a future + * restart's claim acquisition, and need manual termination. */ + proxy.stopAcceptingNewSessions(); + /* Codex PR #29 review round 8 "bound termination of stdio downstream children": + * conn.close() now returns a promise that resolves only once the child has actually + * exited (bounded SIGTERM wait, then SIGKILL) rather than firing SIGTERM and returning + * immediately -- await it here so this cleanup path genuinely finishes terminating + * every downstream child before releasing the writer-claim, instead of leaving a child + * that traps/ignores SIGTERM running past this point. */ + for (const conn of downstreamHandles.connections.values()) { + try { await conn.close(); } catch (closeError) { /* best effort */ } + } + writerClaim.release(); + throw error; + } + + const ctx = { config, writerClaim, connections: downstreamHandles.connections, proxy }; + + /* Cluster D: write an initial status file immediately (not just on the first + * interval tick, options.statusWriteIntervalMs or STATUS_WRITE_INTERVAL_MS away) so a + * `status` invocation right after startup already finds fresh data, then keep it + * refreshed on a fixed cadence for the rest of this process's life. unref'd, matching + * every other background timer in this file (writer-claim's own heartbeat, HTTP + * session idle timers) -- a status-file refresh must never be the reason this process + * fails to exit. */ + writeStatusFile(ctx, log); + const statusWriteIntervalMs = options.statusWriteIntervalMs || STATUS_WRITE_INTERVAL_MS; + const statusWriteTimer = setInterval(() => writeStatusFile(ctx, log), statusWriteIntervalMs); + if (typeof statusWriteTimer.unref === "function") statusWriteTimer.unref(); + + const drainTimeoutMs = options.drainTimeoutMs || 5000; + /* CodeRabbit PR #29 review "make stop() await the in-progress shutdown instead of + * returning early": the SIGTERM/SIGINT handler and the stdio-disconnect path + * (stdioHandle.closed.then(...)) can both call stop(), and both call process.exit(0) + * once THEIR OWN promise settles. The previous `if (stopped) return;` resolved a + * second caller's promise immediately, while the first caller's drain/finalize/ + * writer-claim-release work was still in flight -- letting the second caller's + * process.exit(0) race ahead and kill the process mid-finalize, losing sessions that + * were never sealed/appended to the chain. Memoize the actual shutdown promise instead, + * so every caller (first or later) awaits the SAME work and only resolves once it is + * genuinely done. */ + let stopPromise = null; + function stop(reason) { + if (!stopPromise) stopPromise = doStop(reason); + return stopPromise; + } + async function doStop(reason) { + log(`shutting down (${reason || "requested"}): draining ${proxy.openSessionCount()} open session(s)`); + clearInterval(statusWriteTimer); // Cluster D: stop refreshing the status file once shutdown begins + proxy.stopAcceptingNewSessions(); + /* SS3.7: "finish in-flight sessions" means actually WAIT (bounded) for calls already + * in flight to complete and be recorded with their real result -- not immediately + * truncate the connection and let closeConnection's own pending-call handling mark + * a call that was about to succeed as an artificial "disconnected" error. Only a + * call that is STILL pending once the drain timeout elapses gets that treatment. */ + await drainOpenSessions(proxy, drainTimeoutMs); + agentPusher.current = null; // no agent left to push a forwarded sampling request to + agentPusher.connectionId = null; + if (stdioHandle) stdioHandle.stop(); + if (httpHandle) { + await new Promise((resolve) => { + let settled = false; + const finish = () => { if (!settled) { settled = true; resolve(); } }; + httpHandle.server.close(finish); + const forceCloseTimer = setTimeout(() => { + // Stop accepting new connections is already implied by close() above; this + // forcibly ends any still-open sockets/responses so the callback above (or + // this fallback) fires within the bounded deadline rather than whenever the + // last active response happens to finish. + if (typeof httpHandle.server.closeAllConnections === "function") httpHandle.server.closeAllConnections(); + finish(); + }, HTTP_LISTENER_CLOSE_TIMEOUT_MS); + if (typeof forceCloseTimer.unref === "function") forceCloseTimer.unref(); + }); + } + // Finalize any still-open sessions (drained above, or forced closed after the timeout). + for (const connectionId of Array.from(proxy.sessions.keys())) { + await proxy.closeConnection(connectionId, `gateway shutdown (${reason || "requested"})`); + } + /* Codex PR #29 review round 8 "bound termination of stdio downstream children": same + * reasoning as the startup-failure cleanup above -- await close() so a downstream that + * traps/ignores SIGTERM is actually confirmed gone (or forcibly SIGKILLed) before this + * releases the writer-claim, rather than left running past shutdown. */ + for (const conn of downstreamHandles.connections.values()) { + try { await conn.close(); } catch (error) { /* best effort */ } + } + writerClaim.release(); + // Cluster D: one last write so `gateway.js status` run after this process has + // exited reports an accurate "not claimed by this instance" / drained snapshot + // instead of silently going stale mid-run-looking data. + writeStatusFile(ctx, log); + log("shutdown complete: writer-claim released."); + } + + return { + dormant: false, + proxy, + writerClaim, + config, + stop, + status: () => buildHealthStatus(ctx), + }; +} + +/* Cluster D: `node gateway.js status [configPath]` -- reads the status FILE a running + * gateway (started against the same config) periodically writes to / + * gateway-status.json (see writeStatusFile above) and pretty-prints it. Deliberately a + * separate, short-lived process reading a file, not an RPC to the running gateway or a + * new ops HTTP port (out of scope -- see writeStatusFile's own header comment): this + * mirrors how `node scripts/writer-claim.js status` already reports on-disk state + * without needing a running process to ask. Exits non-zero with a clear, actionable + * message when the config can't be loaded, no status file exists yet (no gateway has + * run against this config, or state_dir doesn't match), or the file is unreadable/ + * corrupt -- never a stack trace. */ +function runStatusCommand(configPathArg) { + const configPath = configPathArg || path.join(process.cwd(), "gateway-config.json"); + let config; + try { + config = gatewayConfig.loadConfig(configPath); + } catch (error) { + console.error(`[graphsmith-gateway] status: could not load config at ${configPath}: ${error.message}`); + process.exitCode = 1; + return; + } + + const statusPath = gatewayStatusPath(config.state_dir); + let raw; + try { + raw = fs.readFileSync(statusPath, "utf8"); + } catch (error) { + if (error.code === "ENOENT") { + console.error( + `[graphsmith-gateway] status: no status file at ${statusPath} -- has a gateway ever been ` + + `started against this config (state_dir: ${config.state_dir})?` + ); + } else { + console.error(`[graphsmith-gateway] status: could not read ${statusPath}: ${error.message}`); + } + process.exitCode = 1; + return; + } + + let status; + try { + status = JSON.parse(raw); + } catch (error) { + console.error(`[graphsmith-gateway] status: ${statusPath} contains invalid JSON: ${error.message}`); + process.exitCode = 1; + return; + } + + console.log(JSON.stringify(status, null, 2)); + + // A status file this stale means the writer that produced it stopped refreshing it -- + // either a clean shutdown (see doStop's own final write) or a crash that skipped that + // final write. Either way, flag it rather than let stale data read as "still running". + const writtenAt = Date.parse(status.written_at); + if (Number.isFinite(writtenAt)) { + const ageMs = Date.now() - writtenAt; + const staleAfterMs = STATUS_WRITE_INTERVAL_MS * 3; + if (ageMs > staleAfterMs) { + console.error( + `[graphsmith-gateway] status: WARNING -- this snapshot is ${Math.round(ageMs / 1000)}s old ` + + `(refreshed every ~${Math.round(STATUS_WRITE_INTERVAL_MS / 1000)}s while running); the ` + + "gateway that wrote it may no longer be running." + ); + } + } +} + +function main() { + const argv = process.argv.slice(2); + if (argv[0] === "status") { + runStatusCommand(argv[1]); + return; + } + + const configPath = argv[0] || path.join(process.cwd(), "gateway-config.json"); + startGateway({ configPath }).then((handle) => { + if (handle.dormant) { + process.exit(0); + } + const shutdown = (signal) => handle.stop(signal).then(() => process.exit(0)); + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); + }).catch((error) => { + console.error(`[graphsmith-gateway] FATAL: ${error.message}`); + process.exitCode = 1; + }); +} + +if (require.main === module) main(); + +module.exports = { + startGateway, + checkModeGate, + buildHealthStatus, + loadSigningKeys, + drainOpenSessions, + gatewayStatusPath, + STATUS_WRITE_INTERVAL_MS, + runStatusCommand, + // Exported for direct unit testing against fake sessions/connections (mirrors + // drainOpenSessions' own existing testability rationale above) -- not part of this + // module's own CLI/programmatic surface otherwise. + forwardDownstreamRequestToAgent, + recordUnmatchedDownstreamResponse, + STARTUP_DOWNSTREAM_CONNECT_TIMEOUT_MS, +}; diff --git a/scripts/gateway/mode-gate.js b/scripts/gateway/mode-gate.js new file mode 100644 index 00000000..d5ffcc5e --- /dev/null +++ b/scripts/gateway/mode-gate.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +/* GraphSmith standalone gateway -- mode-selection gate (SG-FR-1 / SG-FR-2). + * + * Implements the STANDALONE side of MS-FR-1 from the Gateway Mode-Selection Contract + * TRD (.plans/v0.5.0/MODE-SELECTION-CONTRACT-TRD-2026-08-26.md, "Revision 3, final"): + * before this process's own config is even read, .graphsmith/gateway-mode.json MUST be + * read and validated against that contract's schema + HMAC-confirmation check, and the + * process MUST refuse to start (fail-closed) on any of SS7's seven named conditions. + * + * FOLLOW-UP (2026-09-02): this module originally carried a private, disclosed + * transcription of the mode-selection contract's SS5.1 schema + SS7 validation logic + * (see git history for the full rationale) because Track 1.1's real shared module, + * scripts/mode-selection.js, hadn't landed on this branch yet. Track 1.1 is now merged + * in (release/v0.5.0-candidate), so this module delegates directly to + * scripts/mode-selection.js's readAndValidateGatewayMode/computeConfirmationToken + * instead of re-validating the contract itself. No duplicated validation logic remains + * here -- this file is now just the STANDALONE binary's thin call-in point. + * + * Zero-dependency (crypto.createHmac is stdlib, no new runtime dependency), Node >= 18. + */ +"use strict"; + +const path = require("path"); +const modeSelection = require("../mode-selection.js"); + +/* Derived from the shared module's own path helpers (rather than re-declared as + * separate string literals) so these two constants can never drift from what + * scripts/mode-selection.js actually reads/writes. */ +const GATEWAY_MODE_FILE = path.relative(".", modeSelection.gatewayModePath(".")); +const GATEWAY_MODE_KEY_FILE = path.relative(".", modeSelection.gatewayModeKeyPath(".")); + +function fail(message, code) { + const error = new Error(message); + error.code = code; + return error; +} + +/** + * Reads, schema-validates, and HMAC-verifies .graphsmith/gateway-mode.json per MS-FR-1, + * then enforces SS7 condition 7 (wrong-binary) against `expectedMode`. Fails closed + * (throws, never returns a partial/best-guess result) on every condition in SS7's table. + * Thin wrapper around scripts/mode-selection.js's readAndValidateGatewayMode -- the + * contract's validation logic lives there now, not here. + * + * @param {string} root project root (contains .graphsmith/) + * @param {{ expectedMode: "standalone" | "attach" }} options + * @returns {{ mode: string, confirmation: object }} the validated, confirmed record + */ +function readGatewayMode(root, options) { + const expectedMode = options && options.expectedMode; + if (expectedMode !== "standalone" && expectedMode !== "attach") { + throw fail("readGatewayMode: options.expectedMode must be 'standalone' or 'attach'", "INVALID_ARGUMENT"); + } + return modeSelection.readAndValidateGatewayMode(root, { expectedMode }); +} + +module.exports = { + GATEWAY_MODE_FILE, + GATEWAY_MODE_KEY_FILE, + computeConfirmationToken: modeSelection.computeConfirmationToken, + readGatewayMode, +}; diff --git a/scripts/gateway/proxy.js b/scripts/gateway/proxy.js new file mode 100644 index 00000000..ec25f22d --- /dev/null +++ b/scripts/gateway/proxy.js @@ -0,0 +1,709 @@ +#!/usr/bin/env node +/* GraphSmith standalone gateway -- proxy/correlation engine (Standalone Gateway TRD + * SS3.3/SS6). Transport-independent: takes parsed JSON-RPC messages in, returns JSON-RPC + * responses to send back, and calls out to the downstream connections + * (scripts/gateway/downstream.js) and the session engine (scripts/gateway/session.js). + * Kept separate from gateway.js (which owns real stdio/http listeners, process + * lifecycle, and writer-claim) so this dispatch logic is unit-testable without any real + * socket or child process. + * + * Session-boundary resolution (SS3.4): "connection" -- one session per agent connection, + * sealed when the agent disconnects. See scripts/gateway/session.js's header for the + * full rationale; this is the one this build implements. + */ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const session = require("./session.js"); +const chain = require("./chain.js"); + +function fail(message, code = "GATEWAY_PROXY_ERROR") { + const error = new Error(message); + error.code = code; + return error; +} + +/* Codex PR #29 review round 3 "retain sessions when persistence fails": chain. + * appendSession can throw on a transient or environmental failure (ENOSPC, EACCES, a + * one-off filesystem error) well after session.finalizeSession has already produced the + * fully sealed, signed bundle -- until now, that already-computed `sealed` object was + * discarded the moment closeConnection's catch block returned null, leaving nothing but + * onSealFailure's own summary log (call count, pending count) to show a session ever + * existed. This does not change closeConnection's documented null-on-failure contract or + * decide retry/halt policy (a genuine architecture question, given every append also + * upholds the sole-writer chain-sequencing invariant) -- it only keeps the one thing that + * was about to be lost forever: a best-effort, non-throwing write of the sealed bundle to + * a quarantine directory an operator can inspect and manually re-append once the + * underlying failure (e.g. disk full) is resolved. */ +function quarantineSealedBundle(stateDir, connectionId, sealed, cause) { + try { + const dir = path.join(chain.sessionsDir(stateDir), "quarantine"); + fs.mkdirSync(dir, { recursive: true }); + const target = path.join(dir, `${connectionId}-${Date.now()}.json`); + fs.writeFileSync(target, JSON.stringify({ sealed, quarantined_at: new Date().toISOString(), reason: cause && cause.message }, null, 2), { encoding: "utf8", flag: "wx" }); + return target; + } catch (quarantineError) { + // Best effort only: a quarantine-write failure (e.g. the same ENOSPC that caused the + // original append failure) must never mask or replace the original error/callback. + return null; + } +} + +/* MCP sampling requests are how a downstream server asks the AGENT'S model to do + * inference (createMessage) -- SS3.3: "model_call ... is set when the proxied call is + * itself an MCP sampling request rather than an ordinary tool call ... at the protocol + * level, not guess from tool names." Recognized by method name, per the MCP spec's + * sampling capability. */ +function isModelCallMethod(method) { + return method === "sampling/createMessage"; +} + +/* PR #29 review thread 4000335147, frontier-panel review (4/4 independent models + * converged, two rounds of critique) "flag structurally malformed tools/call results + * distinctly from tool-level errors": before this, a `tools/call` result was only ever + * judged by `isError` (Board decision 2026-09-04, above) -- a downstream that returned a + * response with NEITHER a recognizable success shape (`content` array) NOR a recognizable + * error/structured shape (`structuredContent` object, or an explicit `isError` boolean) + * was recorded as a normal, non-error call, because nothing about `isError: true`/`false` + * was ever asserted on it. That is a THIRD state, distinct from both "transport failed" + * and "tool reported isError: true": the downstream answered, but not in any shape the + * MCP CallToolResult contract recognizes at all. This predicate is deliberately narrow -- + * it does not validate per-content-block schemas (e.g. a `content` array full of garbage + * blocks still counts as well-formed here), and extra/unknown fields on an otherwise + * -recognizable object never trip it (the MCP spec allows a server to add fields this + * gateway doesn't know about yet). It is purely an AUDIT annotation on the sealed + * session/trace record -- it never changes `isError`, never changes the JSON-RPC response + * actually returned to the agent, and never gates or blocks anything (see the two call + * sites below). Exception-safe by construction: a thrown error during the check itself + * (e.g. a poisoned getter on a hostile downstream's response object) is treated as "not + * malformed" -- failing open on an audit signal is correct; failing closed on one would + * turn a defensive annotation into an availability risk for a legitimate call. */ +function isMalformedToolCallResult(result) { + try { + if (result === null || typeof result !== "object" || Array.isArray(result)) return true; + const hasContent = Array.isArray(result.content); + const hasStructuredContent = result.structuredContent !== null && result.structuredContent !== undefined && typeof result.structuredContent === "object" && !Array.isArray(result.structuredContent); + const hasIsError = typeof result.isError === "boolean"; + return !(hasContent || hasStructuredContent || hasIsError); + } catch (error) { + return false; + } +} + +/* This build only ever negotiates the one protocol version it actually implements + * (matching the literal default this file used to echo back unconditionally). Kept as a + * named constant so "what we claim to speak" and "what we validate against" cannot drift + * apart independently. */ +const GATEWAY_PROTOCOL_VERSION = "2025-06-18"; + +/* Codex PR #29 review round 5 "cap in-flight calls per agent session": without a bound, + * one authenticated client (stdio, or one HTTP session) could open arbitrarily many + * concurrent tools/call or sampling requests -- each retaining session state, a + * downstream pending entry, and a timer -- without ever waiting for a response. The + * active-session cap (MAX_HTTP_SESSIONS in agent-transport.js) bounds how many SESSIONS + * exist; it does nothing to bound growth WITHIN one session. Same "fixed, + * non-speculative default" discipline as that constant and MAX_TOOLS_LIST_PAGES/ + * MAX_HTTP_RESPONSE_BYTES/MAX_TOTAL_TOOLS_DESCRIPTOR_BYTES in downstream.js -- make it + * configurable once a real deployment needs a different number, not before. */ +const MAX_PENDING_CALLS_PER_SESSION = 1000; + +/* Codex PR #29 review "bound completed call history retained by each session": + * MAX_PENDING_CALLS_PER_SESSION above bounds only calls genuinely IN FLIGHT at once -- + * once a call resolves, session.recordCallResult moves it out of pendingCalls and into + * session.calls (SS3.3's execution_trace), which has no cap at all. A connection with no + * enforced lifetime (stdio) or one that keeps resetting its own idle timer (an active + * HTTP client issuing calls sequentially, never exceeding the pending cap) can therefore + * grow session.calls -- full arguments and result retained per entry -- without bound + * until this process exhausts memory, entirely bypassing the pending-call admission + * check above. Same "fixed, non-speculative default" discipline as that constant. */ +const MAX_COMPLETED_CALLS_PER_SESSION = 100000; + +/* Codex PR #29 re-triage "bound retained session bytes, not only call count": the two + * caps above bound how many calls a session may retain, but not their SIZE -- at the + * declared maxima (MAX_COMPLETED_CALLS_PER_SESSION calls, each able to retain a request + * near the agent input cap and a downstream result near downstream.js's own + * MAX_HTTP_RESPONSE_BYTES) the retained payload budget is roughly a terabyte. A + * sequential authenticated client (never tripping the pending-call cap) or a compromised + * downstream can exhaust memory well before reaching either count limit. Same "fixed, + * non-speculative default" discipline as MAX_PENDING_CALLS_PER_SESSION/ + * MAX_COMPLETED_CALLS_PER_SESSION above -- session.totalCallBytes (session.js) tracks the + * running total this bounds. */ +const MAX_SESSION_CALL_BYTES = 256 * 1024 * 1024; + +/* Per-connection agent-initialization lifecycle states (Codex PR #29 review round 8 + * "wait for notifications/initialized before admitting tools") -- see the + * GatewayProxy#agentInitialized field comment in the constructor for the full rationale. */ +const INIT_NONE = "none"; +const INIT_AWAITING_NOTIFICATION = "awaiting_notification"; +const INIT_READY = "ready"; + +class GatewayProxy { + /** + * @param {object} opts + * @param {Map} opts.connections serverName -> downstream connection (scripts/gateway/downstream.js) + * @param {Array<{name,server,schema}>} opts.mergedTools the cached, startup-time tool surface (SS3.2) + * @param {Map} opts.toolOwners toolName -> serverName + * @param {object} opts.serverInfos serverName -> serverInfo (from startup handshake) + * @param {object} opts.keys sealBoundaryBundle's signing keys (SS3.5) + * @param {(connectionId: string, entry: object, sealed: object) => void} [opts.onSessionFinalized] + * @param {(session: object, error: Error) => void} [opts.onSealFailure] SS7: sealBoundaryBundle throws + * @param {string} opts.stateDir passed straight to chain.appendSession (SG-FR-5) + * @param {() => number} [opts.now] + * @param {() => boolean} [opts.isWriterClaimValid] Cluster C narrow fix: a synchronous, + * read-fresh-from-disk liveness check (e.g. `() => writerClaim.status().held_by_this_instance`) + * consulted immediately before chain.appendSession in closeConnection -- see that method's + * own comment for why this is checked THERE rather than by halting every in-flight session + * the moment onClaimLost first fires. Defaults to always-valid so existing callers/tests that + * construct a GatewayProxy directly (no writer-claim of their own) are unaffected, mirroring + * onSessionFinalized/onSealFailure's own default-no-op contract above. + * @param {() => void} [opts.renewWriterClaim] Re-triage fix: a synchronous + * `() => writerClaim.renew()` consulted immediately before chain.appendSession in + * closeConnection, alongside isWriterClaimValid above -- writer-claim.js's own + * startHeartbeat() doc comment warns that a long synchronous critical section (this + * append) can starve the setInterval-driven heartbeat past staleAfterMs, letting the + * claim go stale even though isWriterClaimValid's read-fresh-from-disk check passed a + * moment earlier. Renewing synchronously right before the write closes that gap. + * Thrown failures are handled by the same try/catch as chain.appendSession itself + * (quarantine + onSealFailure + return null -- see closeConnection). Defaults to a + * no-op so existing callers/tests with no writer-claim of their own are unaffected, + * mirroring isWriterClaimValid's own default-no-op contract above. + */ + constructor(opts) { + this.connections = opts.connections; + this.mergedTools = opts.mergedTools; + this.toolOwners = opts.toolOwners; + this.serverInfos = opts.serverInfos || {}; + this.keys = opts.keys; + this.onSessionFinalized = opts.onSessionFinalized || (() => {}); + this.onSealFailure = opts.onSealFailure || (() => {}); + this.stateDir = opts.stateDir; + this.now = opts.now || (() => Date.now()); + this.isWriterClaimValid = typeof opts.isWriterClaimValid === "function" ? opts.isWriterClaimValid : () => true; + this.renewWriterClaim = typeof opts.renewWriterClaim === "function" ? opts.renewWriterClaim : () => {}; + /* Codex PR #29 review "treat sampling as a negotiated client capability" (comment + * 4000335132): whether THIS gateway instance negotiated the sampling capability at + * all (gateway.js's own agentTransportSupportsSampling -- the same fact already used + * to advertise `capabilities: { sampling: {} }` to each downstream and to gate + * forwardDownstreamRequestToAgent). Snapshotted once here (not read live at seal + * time) and stamped onto every session this proxy opens, so a model_call's granted + * status in the sealed bundle reflects what was actually negotiated for THAT + * connection, not whatever this mutable fact happens to be when the session finalizes. */ + this.agentSupportsSampling = Boolean(opts.agentSupportsSampling); + this.sessions = new Map(); // connectionId -> in-memory session (scripts/gateway/session.js) + this.acceptingNewSessions = true; // SS3.7/SS7: false once writer-claim is lost + this.downstreamCallIds = new Map(); // `${connectionId}:${agentJsonRpcId}` -> { server, downstreamId } (SS3.3 cancellation) + /* Per-connection agent-initialization lifecycle (board decision 2026-09-04, PR #29 + * review "enforce the agent initialization lifecycle"): tracked here rather than on + * the session.js record itself, since it is purely a dispatch-gating concern of this + * proxy, not part of the sealed session's own attested shape. */ + /* Codex PR #29 review round 8 "wait for notifications/initialized before admitting + * tools": three states, not a boolean -- INIT_NONE (no "initialize" yet), + * INIT_AWAITING_NOTIFICATION ("initialize" response computed/sent, but the client's + * required "notifications/initialized" has not yet arrived), and INIT_READY (that + * notification has been received). Tools are admitted only in INIT_READY: an + * "initialize" response alone does not prove the client ever received it (it may have + * been sent as a bare notification with no id at all), so admitting tools immediately + * after computing that response could unlock tools/list and tools/call before the + * client has actually completed the MCP-required handshake. */ + this.agentInitialized = new Map(); // connectionId -> "none" | "awaiting_notification" | "ready" + /* Optional structured per-call log sink (board decision 2026-09-04, PR #29 review + * "emit the required structured log for each call") -- defaults to a no-op so unit + * tests that construct a GatewayProxy directly (no logging concern of their own) + * stay silent, mirroring onSessionFinalized/onSealFailure's own default-no-op + * contract above. */ + this.log = typeof opts.log === "function" ? opts.log : (() => {}); + } + + /** Codex PR #29 review round 8 "contain logger failures after downstream effects": a + * configured `log` callback (opts.log above) is caller-supplied operational plumbing, + * not part of this proxy's own call-completion contract -- if it throws, the exception + * would otherwise escape handleMessage AFTER a downstream tool has already succeeded and + * had its result recorded, turning a real success into a JSON-RPC internal error sent to + * the agent and inviting a retry that repeats the (possibly non-idempotent) external + * effect. Every call site that logs after a downstream effect has already landed must go + * through this non-throwing wrapper instead of calling this.log directly. */ + safeLog(payload) { + try { + this.log(payload); + } catch (error) { + // Deliberately swallowed: a failing logger must never alter call-completion + // semantics or the response already being returned to the agent. + } + } + + openConnection(connectionId, options = {}) { + if (!this.acceptingNewSessions) { + throw fail("Gateway is no longer accepting new sessions (writer-claim lost or shutting down)", "GATEWAY_NOT_ACCEPTING"); + } + if (this.sessions.has(connectionId)) throw fail(`connectionId "${connectionId}" is already open`, "GATEWAY_DUPLICATE_CONNECTION"); + const s = session.createSession(connectionId, { now: this.now, goal: options.goal, samplingNegotiated: this.agentSupportsSampling }); + /* SS3.3: the granted tool surface must be recorded regardless of whether the agent + * ever bothers to issue tools/list on this connection -- otherwise a cached tool + * invoked without a prior tools/list would be sealed with an empty granted surface, + * making sealBoundaryBundle's granted-tool check falsely report "not granted" for a + * call the gateway legitimately authorized. The (idempotent) tools/list handler below + * simply re-records the same surface if the agent does ask. */ + session.recordToolsList(s, this.mergedTools); + this.sessions.set(connectionId, s); + this.agentInitialized.set(connectionId, INIT_NONE); + return s; + } + + /** Dispatches one JSON-RPC request/notification from an agent on `connectionId`. + * Returns the JSON-RPC response object to send back, or null for a notification / + * a message the agent must not receive a reply to. Never throws for a well-formed + * JSON-RPC envelope -- protocol-shaped errors come back as JSON-RPC error objects. */ + async handleMessage(connectionId, msg) { + const s = this.sessions.get(connectionId); + if (!s) throw fail(`No open session for connectionId "${connectionId}"`, "GATEWAY_UNKNOWN_CONNECTION"); + if (!msg || typeof msg !== "object" || Array.isArray(msg) || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") { + return { jsonrpc: "2.0", id: msg && typeof msg === "object" && !Array.isArray(msg) ? msg.id : null, error: { code: -32600, message: "Malformed JSON-RPC 2.0 request envelope." } }; + } + /* Codex PR #29 review round 6 "reject invalid JSON-RPC identifier types": JSON-RPC + * 2.0 restricts "id" to a string, a number, or null (a request with no "id" at all is + * a notification, handled via isNotification below) -- this check previously accepted + * any other type (boolean, object, array) since it only ever compared id to + * `undefined`. For "tools/call" that let a malformed id reach dispatch, execute a real + * downstream side effect, and come back in a response/correlation record no + * spec-conforming caller could use. Reject before computing isNotification/dispatch; + * the offending id is never echoed back since its type is exactly what is invalid. */ + /* CodeRabbit PR #29 review round 7 "reject numeric identifiers outside the + * safe-integer range": handleMessage uses a numeric id both as a pendingCalls Map key + * and (serialized) as a downstream correlation id -- JS numbers cannot distinguish + * some adjacent JSON integers once they exceed Number.MAX_SAFE_INTEGER (or are + * non-finite), so two distinct requests could collide on the same correlation key. + * The round-6 fix above only ruled out non-string/non-number/non-null types, not an + * out-of-safe-range or non-finite number. */ + const invalidNumericId = typeof msg.id === "number" && (!Number.isFinite(msg.id) || !Number.isSafeInteger(msg.id)); + const invalidIdType = msg.id !== undefined && msg.id !== null && typeof msg.id !== "string" && typeof msg.id !== "number"; + if (invalidIdType || invalidNumericId) { + return { jsonrpc: "2.0", id: null, error: { code: -32600, message: `Invalid JSON-RPC "id": must be a string, a finite safe-integer number, or null (or omitted for a notification).` } }; + } + const { method, params, id } = msg; + const isNotification = id === undefined; + + if (method === "notifications/cancelled") { + /* Codex PR #29 review round 5 "continue processing cancellations while draining": + * this must run BEFORE the acceptingNewSessions gate below, not after -- a + * cancellation notification for an already-in-flight call is not "new work" the + * drain is refusing to admit, it is how an agent asks the gateway to stop existing + * work SOONER. Gating it behind acceptingNewSessions silently dropped every + * cancellation once shutdown began, so an agent's cancel request during drain could + * never reach conn.cancel() and the gateway would wait out the full drain deadline + * even though the agent had already asked to stop the call. + * + * SS3.3: downstream calls run under a gateway-internal id, not the agent's own + * JSON-RPC id, so a bare pass-through of the cancellation payload would target the + * wrong id on the downstream leg (or no id at all, for a downstream that happens to + * reuse numbering). Translate via the mapping recorded when the call started. */ + const targetRequestId = params && params.requestId; + const key = `${connectionId}:${JSON.stringify(targetRequestId)}`; + const mapping = this.downstreamCallIds.get(key); + if (mapping) { + const conn = this.connections.get(mapping.server); + if (conn && typeof conn.cancel === "function") { + try { conn.cancel(mapping.downstreamId); } catch (error) { /* best effort */ } + } + } + return null; + } + + /* SS3.7/SS7: once the writer-claim is lost, stop admitting NEW work on every + * connection, not just new connections (openConnection already refuses those) -- + * otherwise an already-open agent connection could keep issuing calls indefinitely + * after a replacement writer has acquired the state directory, risking concurrent + * chain-append corruption. A call already in flight (already past this point in an + * earlier handleMessage invocation, already awaiting its downstream response) is + * unaffected and is allowed to drain normally -- only messages that arrive AFTER the + * flag flips are refused. notifications/cancelled is exempted above: it never admits + * new work, only stops existing work sooner. */ + if (!this.acceptingNewSessions) { + if (isNotification) return null; + return { jsonrpc: "2.0", id, error: { code: -32000, message: "Gateway is draining (writer-claim lost or shutting down): not accepting new requests on this connection." } }; + } + + if (method === "initialize") { + /* Board decision 2026-09-04, PR #29 review "enforce the agent initialization + * lifecycle": a second initialize on an already-initialized connection would + * silently overwrite the metadata already attached to calls made under the first + * one (session.recordInitialize below just replaces the recorded clientInfo/ + * serverInfo/model), producing a sealed trace whose initialize record no longer + * matches what was actually true when those earlier calls ran. Reject it instead. */ + if (this.agentInitialized.get(connectionId) !== INIT_NONE) { + if (isNotification) return null; + return { jsonrpc: "2.0", id, error: { code: -32600, message: "This connection has already completed \"initialize\" -- a repeated initialize is not permitted." } }; + } + /* SS3.2/SS6: the downstream handshake already ran once at gateway startup; this + * records the AGENT's own clientInfo alongside the already-cached downstream + * serverInfo (merged: single server's info verbatim, or a composite name when + * more than one downstream is configured, so session.initialize never silently + * picks just one of several fronted servers). */ + const serverNames = Object.keys(this.serverInfos); + const mergedServerInfo = serverNames.length === 1 + ? this.serverInfos[serverNames[0]] + : { name: "graphsmith-standalone-gateway(" + serverNames.join("+") + ")", version: "1.0", fronted: this.serverInfos }; + session.recordInitialize(s, { + clientInfo: params && params.clientInfo, + serverInfo: mergedServerInfo, + model: params && params.model, + }); + /* Codex PR #29 review round 8 "wait for notifications/initialized before admitting + * tools": this used to flip straight to "tools admitted" here -- before the agent + * has necessarily received this very response (an id-less "initialize" sent as a + * notification reaches this same assignment) and before the client has sent the + * MCP-required "notifications/initialized" completion. Move to the intermediate + * state instead; only that notification (handled below) advances to INIT_READY. */ + this.agentInitialized.set(connectionId, INIT_AWAITING_NOTIFICATION); + if (isNotification) return null; + /* This gateway implements exactly one protocol version (GATEWAY_PROTOCOL_VERSION); + * echoing back whatever the agent asked for (SS3.3) would let a client believe + * initialization succeeded under a contract this build does not actually implement, + * causing later requests to be misinterpreted per the client's own (wrong) + * assumption. Always return the version actually selected, never the request. */ + return { jsonrpc: "2.0", id, result: { protocolVersion: GATEWAY_PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: mergedServerInfo } }; + } + + if (method === "notifications/initialized") { + /* Codex PR #29 review round 8: only advance out of the intermediate state -- a + * stray "notifications/initialized" with no preceding "initialize" (state INIT_NONE) + * or a repeat of it (state already INIT_READY) is harmless to ignore, not something + * to newly admit tools for. */ + if (this.agentInitialized.get(connectionId) === INIT_AWAITING_NOTIFICATION) { + this.agentInitialized.set(connectionId, INIT_READY); + } + return null; + } + + if (method.startsWith("notifications/")) return null; + + /* Board decision 2026-09-04, PR #29 review "enforce the agent initialization + * lifecycle": without this gate, an agent could list or invoke tools under an empty + * initialization record (no clientInfo/serverInfo ever attested for that session), + * which sealBoundaryBundle would then attest as if it were a normal, complete + * session. Gates only the two AGENT-initiated methods this applies to -- a + * downstream-pushed sampling/createMessage is not agent-initiated and is unaffected. */ + if ((method === "tools/list" || method === "tools/call") && this.agentInitialized.get(connectionId) !== INIT_READY) { + if (isNotification) return null; + return { jsonrpc: "2.0", id, error: { code: -32600, message: `Cannot call "${method}" before this connection has completed "initialize" and sent "notifications/initialized".` } }; + } + + if (method === "tools/list") { + session.recordToolsList(s, this.mergedTools); + if (isNotification) return null; + /* Codex PR #29 review "forward the full cached tool descriptor": downstream.js's + * connectAllDownstreams already preserves each tool's complete descriptor + * (description, outputSchema, annotations, title, etc. -- see its own comment + * "Preserve the tool's full descriptor ... rather than keeping only name+schema") + * onto `t`, alongside two gateway-private bookkeeping fields it adds: `server` + * (tool ownership, internal routing only) and `schema` (an internal alias of + * `inputSchema` session.js/this dispatch code reads). Projecting down to just + * {name, description, inputSchema} here discarded everything else the downstream + * actually advertised, handing the agent a narrower tool contract than the + * downstream provides -- e.g. no outputSchema for a client that validates + * structured results against it. Strip only the two gateway-private fields; forward + * the rest of the real descriptor (including the original `inputSchema`) verbatim. */ + return { jsonrpc: "2.0", id, result: { tools: this.mergedTools.map((t) => { const { server, schema, ...descriptor } = t; return descriptor; }) } }; + } + + if (method === "tools/call" || isModelCallMethod(method)) { + const toolName = method === "tools/call" ? params && params.name : "sampling/createMessage"; + const serverName = method === "tools/call" ? this.toolOwners.get(toolName) : (params && params.server); + if (method === "tools/call" && !serverName) { + const error = { code: -32602, message: `Unknown tool "${String(toolName)}" -- not present in this gateway's granted tool surface.` }; + if (isNotification) return null; + return { jsonrpc: "2.0", id, error }; + } + /* Codex PR #29 review round 5 "cap in-flight calls per agent session": refuse + * admitting another concurrent call once this session already has + * MAX_PENDING_CALLS_PER_SESSION genuinely pending, rather than let one session's + * own pending-call bookkeeping (and the downstream timers/sockets each entry + * retains) grow without bound. */ + if (s.pendingCalls.size >= MAX_PENDING_CALLS_PER_SESSION) { + const error = { code: -32000, message: `This session already has ${MAX_PENDING_CALLS_PER_SESSION} call(s) pending -- refusing to admit another concurrent call until at least one resolves.` }; + if (isNotification) return null; + return { jsonrpc: "2.0", id, error }; + } + /* Codex PR #29 review "bound completed call history retained by each session": a + * client issuing calls one at a time (never tripping the pending-call cap above) + * can still grow this session's completed-call history without bound over the + * connection's lifetime. Refuse admission the same way the pending-call cap does -- + * the client must end this session and start a new one -- rather than let one + * long-lived connection's retained history grow unbounded. */ + /* CodeRabbit PR #29 review round 8 "reserve capacity for pending calls": this check + * previously counted only s.calls.length (already-COMPLETED calls), ignoring calls + * already admitted and genuinely in flight (s.pendingCalls). With, say, 99,999 + * completed calls and several concurrent in-flight requests all passing this check + * before any of them resolves into s.calls, s.calls.length could grow past + * MAX_COMPLETED_CALLS_PER_SESSION once they all completed. Count pending calls + * against the same budget so the cap bounds total retained-call growth, not just + * calls that happen to have already resolved at check time. */ + if (s.calls.length + s.pendingCalls.size >= MAX_COMPLETED_CALLS_PER_SESSION) { + const error = { code: -32000, message: `This session has already completed ${MAX_COMPLETED_CALLS_PER_SESSION} call(s) -- refusing to admit another call on this connection; start a new session.` }; + if (isNotification) return null; + return { jsonrpc: "2.0", id, error }; + } + /* Codex PR #29 re-triage "bound retained session bytes, not only call count": see + * MAX_SESSION_CALL_BYTES's own comment above. Mirrors the two count-based checks + * above exactly -- same admission point, same error family, same refuse-until-a- + * new-session shape. */ + if (s.totalCallBytes >= MAX_SESSION_CALL_BYTES) { + const error = { code: -32000, message: `This session has already retained ${MAX_SESSION_CALL_BYTES} bytes of call payloads -- refusing to admit another call on this connection; start a new session.` }; + if (isNotification) return null; + return { jsonrpc: "2.0", id, error }; + } + const conn = method === "tools/call" ? this.connections.get(serverName) : this.connections.values().next().value; + const callArgs = method === "tools/call" ? (params && params.arguments) : params; + const ts = this.now(); + /* Correlate by an internally-generated marker even for notification-shaped calls + * (SS3.3's Map-keyed-by-id requirement is about the DOWNSTREAM leg's own id, which + * downstream.js already manages; here we key the SESSION record by the AGENT's own + * JSON-RPC id when present, or a synthetic one for a fire-and-forget call). */ + /* Codex PR #29 review "handle JSON-RPC null IDs before recording calls": JSON-RPC + * 2.0 permits an explicit `id: null` on a REQUEST (distinct from a notification, + * which omits the id key entirely) -- `isNotification` above only catches the + * latter. Using `id` (null) directly as this call's correlation key made it into + * session.recordCallStart's Map key, which explicitly throws INVALID_ARGUMENT on a + * null/undefined key -- turning a legal-if-unusual request into an uncaught + * internal error instead of a normal response. A null id still gets a real + * response below (the outer `id` variable, unchanged, is echoed back as JSON-RPC + * requires) -- only the internal bookkeeping key needs to never be null. */ + const correlationKey = isNotification || id === null ? Symbol(`${isNotification ? "notify" : "null-id"}:${toolName}`) : id; + session.recordCallStart(s, correlationKey, { tool: toolName, server: method === "tools/call" ? serverName : "sampling", arguments: callArgs, isModelCall: isModelCallMethod(method), ts }); + const cancelKey = !isNotification ? `${connectionId}:${JSON.stringify(id)}` : null; + let result, transportFailed = false; + try { + result = await conn.call(method, params, undefined, cancelKey + ? (downstreamId) => this.downstreamCallIds.set(cancelKey, { server: serverName, downstreamId }) + : undefined); + } catch (error) { + /* Board decision 2026-09-04, PR #29 review "preserve downstream JSON-RPC error + * envelopes": downstream.js's connectStdio/connectHttp both already attach the + * original JSON-RPC error object (code, message, optional data) as `error. + * rpcError` -- kept here so the response below can propagate it instead of + * flattening every downstream failure into a generic -32000. */ + result = { error: error.message, code: error.code, rpcError: error.rpcError || null }; + transportFailed = true; + } finally { + if (cancelKey) this.downstreamCallIds.delete(cancelKey); + } + /* Board decision 2026-09-04, PR #29 review "honor MCP tool-level error results": + * a `tools/call` result can be a structurally successful, well-transported MCP + * response that nonetheless carries `isError: true` on the result itself (the MCP + * spec's own way for a TOOL's execution to fail, distinct from a transport/RPC + * failure). That must be reflected in the SESSION record (sealed trace, output + * manifest) -- but per MCP semantics a tool-level error is still a normal + * `tools/call` RESULT, not a JSON-RPC protocol error, so the response sent back to + * the agent below is still keyed on `transportFailed` alone, unchanged. */ + const toolLevelError = !transportFailed && method === "tools/call" && result && typeof result === "object" && result.isError === true; + const isError = transportFailed || toolLevelError; + /* PR #29 review thread 4000335147, frontier-panel review (4/4 converged) "flag + * structurally malformed tools/call results distinctly from tool-level errors": + * this is now a THREE-state audit model for a `tools/call` outcome -- transport + * error (`transportFailed`), tool-level error (`toolLevelError`, an `isError: true` + * on an otherwise well-formed result), and malformed shape (`malformedResult`, + * neither of the above but also not a recognizable CallToolResult at all). All + * three can be reasoned about independently -- `malformedResult` and `isError` are + * NOT mutually exclusive with each other, and a call can be malformed without being + * an error or vice versa. Scoped to `tools/call` only (never `sampling/ + * createMessage` -- a model_call's result shape is not a CallToolResult and this + * predicate says nothing about it) and only when the downstream actually responded + * (`!transportFailed` -- a transport failure already gets its own, unrelated + * synthetic `result` shape assigned in the catch block above, which this must not + * re-judge). This is audit-only: it does not change `isError`, does not change the + * JSON-RPC response returned to the agent below (still the raw, untouched + * `result`), and is not a validation gate -- see isMalformedToolCallResult's own + * comment for what it deliberately does not check (per-content-block schemas are + * out of scope). */ + const malformedResult = !transportFailed && method === "tools/call" && isMalformedToolCallResult(result); + const completedAt = this.now(); + /* CodeRabbit PR #29 review "recording the result after the session is closed can + * throw or write a false anomaly": closeConnection()/handleDownstreamDisconnect() + * can run for this same connectionId WHILE conn.call() above is still being + * awaited (both call session.markPendingAsDisconnected, which removes the pending + * entry). If that happened, this call's correlationKey is no longer a real pending + * call by the time the response lands: recording it anyway would either throw + * SESSION_FINALIZED (if the session already finished finalizing -- escaping + * handleMessage's own "never throws for a well-formed envelope" contract) or push a + * spurious UNMATCHED_RESPONSE anomaly (if not yet finalized) for an entry the + * gateway itself removed, not a real downstream protocol violation. Only record + * (and log) when the call is still genuinely pending. */ + const correlatedNow = !s.finalized && s.pendingCalls.has(correlationKey); + if (correlatedNow) { + session.recordCallResult(s, correlationKey, { result, isError, malformedResult, ts: completedAt }); + /* Board decision 2026-09-04, PR #29 review "emit the required structured log for + * each call": the only prior gateway log for a call was the session-finalize log + * emitted much later (or never, if the process crashes first) -- this gives every + * completed call its own operational line, regardless of how the session ends. */ + const recordedCall = s.calls[s.calls.length - 1]; + this.safeLog(JSON.stringify({ + event: "gateway_call_completed", + connection_id: connectionId, + step: recordedCall ? recordedCall.seq : null, + tool: toolName, + server: method === "tools/call" ? serverName : "sampling", + status: isError ? "error" : "ok", + duration_ms: completedAt - ts, + /* Mirrors sealBoundaryBundle's own `malformed_result` trace field (gsa-mcp- + * shim.js) into the operational log, additive-only: omitted entirely for a + * well-formed call so existing log consumers parsing this line see no change. */ + ...(malformedResult ? { malformed_result: true } : {}), + })); + } + if (isNotification) return null; + if (transportFailed) { + const rpcError = result.rpcError; + return { + jsonrpc: "2.0", + id, + error: rpcError && typeof rpcError === "object" && typeof rpcError.code === "number" + ? { code: rpcError.code, message: rpcError.message || "downstream call failed", ...(rpcError.data !== undefined ? { data: rpcError.data } : {}) } + : { code: -32000, message: typeof result.error === "string" ? result.error : "downstream call failed" }, + }; + } + return { jsonrpc: "2.0", id, result }; + } + + if (isNotification) return null; + return { jsonrpc: "2.0", id, error: { code: -32601, message: `Unknown method: "${method}".` } }; + } + + /** Codex PR #29 review round 4 "log calls finalized as disconnected": every OTHER path + * that finishes a call (GatewayProxy#handleMessage's own completion above) emits a + * structured "gateway_call_completed" log line for it; a call finalized instead via + * session.markPendingAsDisconnected got no such line from anywhere -- handleMessage + * can't log it later because the pending entry is already gone by the time its own + * response (if any) arrives. Shared by both call sites below so the log shape stays + * identical to handleMessage's own. */ + logDisconnectedCall(connectionId, call, at) { + this.safeLog(JSON.stringify({ + event: "gateway_call_completed", + connection_id: connectionId, + step: call.seq, + tool: call.tool, + server: call.server, + status: "disconnected", + duration_ms: at - call.ts, + })); + } + + /** Called when a downstream connection drops mid-session (SS7): marks only the pending + * calls actually routed to `reasonServerName` as disconnected, across every open + * session. A pending call to a DIFFERENT, still-healthy downstream is left alone -- if + * it later succeeds, the persisted trace must show that real success, not a false + * "disconnected" error borrowed from an unrelated server's failure. */ + handleDownstreamDisconnect(reasonServerName) { + for (const s of this.sessions.values()) { + session.markPendingAsDisconnected( + s, + `downstream server "${reasonServerName}" disconnected`, + this.now, + reasonServerName, + (call, at) => this.logDisconnectedCall(s.connectionId, call, at) + ); + } + } + + /** Finalizes and persists one connection's session (SS3.4/SS3.5/SS6 steps 8-10). + * Any pending calls are first marked disconnected (SS7: "any of that connection's + * in-flight calls that never get a response must be recorded ... never silently + * dropped") -- covers both a genuine downstream disconnect and an agent that hangs up + * mid-call. Returns the appended chain entry, or null if sealing/persistence failed, or + * if this instance's writer-claim was no longer valid at append time (Cluster C -- see + * the isWriterClaimValid check below) -- all three already reported via onSealFailure. */ + async closeConnection(connectionId, reason) { + const s = this.sessions.get(connectionId); + if (!s) return null; + if (s.pendingCalls.size > 0) { + session.markPendingAsDisconnected( + s, + reason || "connection closed with calls still pending", + this.now, + undefined, + (call, at) => this.logDisconnectedCall(connectionId, call, at) + ); + } + this.sessions.delete(connectionId); + this.agentInitialized.delete(connectionId); + let sealed; + try { + sealed = session.finalizeSession(s, this.keys); + } catch (error) { + this.onSealFailure(s, error); + return null; + } + /* Cluster C narrow fix (board decision): onClaimLost (gateway.js) only calls + * proxy.stopAcceptingNewSessions() -- it deliberately does NOT force-close sessions + * already open at the moment claim loss is detected (SS7: "in-flight sessions ... + * should still attempt to finalize and persist"). But this integrity-critical write + * is exactly the point where the single-writer invariant chain.appendSession depends + * on can actually be violated: if a REPLACEMENT writer has since acquired this state + * directory (this instance's claim record was stolen/expired) and is itself + * appending, this session's append would interleave with the new writer's and + * corrupt the shared chain. Re-validate the claim synchronously, right here, right + * before the write that matters -- not eagerly at claim-loss-detection time (that + * would abandon every in-flight session's work the instant a heartbeat renewal + * failed, which is the broader behavior SS7 explicitly did not ask for) and not by + * trusting a value cached earlier (isWriterClaimValid reads fresh from disk -- see + * WriterClaim#status's own doc comment -- so a claim lost between this session + * opening and finalizing now is still caught). A stale/lost claim is treated exactly + * like a persistence failure below: quarantine the already-sealed bundle so an + * operator or the next writer can inspect/replay it, report via onSealFailure, and + * return null -- never append. */ + if (!this.isWriterClaimValid()) { + const error = fail( + `Refusing to append session ${connectionId} to the gateway-session chain: this ` + + "instance's writer-claim is no longer valid (lost or superseded between this " + + "session opening and its finalization). Appending now would risk two writers " + + "interleaving chain entries, corrupting the single-writer hash-chain invariant -- " + + "quarantining the sealed bundle instead of appending it.", + "GATEWAY_WRITER_CLAIM_LOST_AT_APPEND" + ); + error.quarantinedTo = quarantineSealedBundle(this.stateDir, connectionId, sealed, error); + this.onSealFailure(s, error); + return null; + } + + /* CodeRabbit PR #29 review "chain.appendSession is not guarded, so a persistence + * failure aborts shutdown and leaks the writer-claim": the doc comment above this + * method says closeConnection returns null "if sealing/persistence failed", but only + * session.finalizeSession was ever inside a try/catch -- chain.appendSession can + * throw GATEWAY_BUNDLE_ID_COLLISION (sealBoundaryBundle derives bundle_id from + * {init, grantedTools, n} alone, so the same agent reconnecting with identical + * clientInfo and call count reproduces it) or on any filesystem error. Uncaught, that + * throw escapes closeConnection entirely -- inside gateway.js's stop()/doStop() + * finalize loop this would abort the remaining sessions' finalization, skip every + * downstream conn.close(), and skip writerClaim.release(), leaking a stale claim that + * blocks the next gateway start. Route it through the same onSealFailure/return-null + * contract as a sealing failure so the documented behavior actually holds. */ + let entry; + try { + /* Re-triage fix: renew the writer-claim synchronously immediately before the + * durable write, right alongside the isWriterClaimValid re-check above -- see + * this.renewWriterClaim's own doc comment in the constructor for why. A thrown + * renewal failure (e.g. WRITER_CLAIM_LOST) falls through to the same + * quarantine-and-report handling as an appendSession failure below, exactly as it + * should: either way, this instance can no longer be trusted to append safely. */ + this.renewWriterClaim(); + entry = chain.appendSession(this.stateDir, sealed); + } catch (error) { + error.quarantinedTo = quarantineSealedBundle(this.stateDir, connectionId, sealed, error); + this.onSealFailure(s, error); + return null; + } + this.onSessionFinalized(connectionId, entry, sealed); + return entry; + } + + /** SS3.7: stop admitting new sessions (writer-claim lost, or graceful shutdown + * draining). Already-open sessions are unaffected and may still be closed normally. */ + stopAcceptingNewSessions() { + this.acceptingNewSessions = false; + } + + openSessionCount() { + return this.sessions.size; + } +} + +module.exports = { GatewayProxy, isModelCallMethod, isMalformedToolCallResult, MAX_PENDING_CALLS_PER_SESSION, MAX_COMPLETED_CALLS_PER_SESSION, MAX_SESSION_CALL_BYTES }; diff --git a/scripts/gateway/session.js b/scripts/gateway/session.js new file mode 100644 index 00000000..e37afd1e --- /dev/null +++ b/scripts/gateway/session.js @@ -0,0 +1,356 @@ +#!/usr/bin/env node +/* GraphSmith standalone gateway -- session capture, correlation, and finalization + * (Standalone Gateway TRD SS3.3/SS3.4/SS3.5). Pure, transport-independent functions: + * no fs/net/process here, so this is testable without spinning up real connections. + * + * SS3.4 (session boundary): this build resolves the TRD's own explicitly-unresolved + * open design point in favor of the TRD's OWN stated lean (SS10, item 1) -- + * "connection-lifetime": one session per agent connection, sealed on disconnect. This + * is the config's `session_boundary: "connection"` default (see scripts/gateway/ + * config.js). "time_window" is accepted by the config schema as a placeholder for a + * later increment but is NOT implemented by this build -- selecting it currently + * throws NOT_IMPLEMENTED at gateway startup rather than silently behaving like + * "connection" (see gateway.js). Disclosed in the build report, not silently guessed. + * + * Concurrency requirement (SS3.3, stated explicitly because it is easy to get wrong): + * correlation is keyed by JSON-RPC `id` via a Map, never by arrival order -- a response + * can legitimately arrive for any pending id in any order, and a response for an id that + * was never sent must be recorded as an anomaly rather than crash the proxy. + */ +"use strict"; + +const { sealBoundaryBundle } = require("../gsa-mcp-shim.js"); + +function fail(message, code = "GATEWAY_SESSION_ERROR") { + const error = new Error(message); + error.code = code; + return error; +} + +/** Creates a fresh, empty in-memory session record (SS5.1's shape). */ +function createSession(connectionId, options = {}) { + if (typeof connectionId !== "string" || connectionId.length === 0) { + throw fail("connectionId must be a non-empty string", "INVALID_ARGUMENT"); + } + return { + connectionId, + initialize: null, + tools: [], + calls: [], + anomalies: [], + pendingCalls: new Map(), + goal: typeof options.goal === "string" ? options.goal : undefined, + startedAt: typeof options.now === "function" ? options.now() : Date.now(), + finalized: false, + nextCallSeq: 1, // monotonic per-session invocation counter; see recordCallStart. + /* Re-triage fix: running total of retained call payload bytes (request params + + * response result, serialized -- see proxy.js's MAX_SESSION_CALL_BYTES for why), + * updated alongside session.calls in recordCallResult/markPendingAsDisconnected. */ + totalCallBytes: 0, + /* Codex PR #29 review "treat sampling as a negotiated client capability" (comment + * 4000335132): whether this connection negotiated the sampling capability (set by + * GatewayProxy#openConnection from gateway.js's own agentTransportSupportsSampling). + * Carried through toSealableSession below so sealBoundaryBundle (gsa-mcp-shim.js) can + * grant a model_call entry by this fact instead of checking sampling/createMessage + * against the downstream tools/list surface, which was never meant to cover it. */ + samplingNegotiated: Boolean(options.samplingNegotiated), + }; +} + +/** Serialized byte size of a JSON-RPC-safe value (request params or a call result), + * matching downstream.js's own MAX_TOTAL_TOOLS_DESCRIPTOR_BYTES style + * (Buffer.byteLength(JSON.stringify(...))). `value` always originated from a JSON-parsed + * wire message, so JSON.stringify cannot throw here -- the `|| ""` guard only covers the + * `undefined` case (e.g. an argument-less call), where JSON.stringify itself returns + * `undefined` rather than a string. */ +function payloadByteSize(value) { + return Buffer.byteLength(JSON.stringify(value) || ""); +} + +/** Records the initialize handshake verbatim (SS3.3). Downstream serverInfo responses + * are merged by the caller (SS3.2, one gateway may front several servers) before being + * passed here as a single merged object. */ +function recordInitialize(session, initializeInfo) { + if (session.finalized) throw fail("Cannot record into a finalized session", "SESSION_FINALIZED"); + session.initialize = { + clientInfo: (initializeInfo && initializeInfo.clientInfo) || null, + serverInfo: (initializeInfo && initializeInfo.serverInfo) || null, + model: (initializeInfo && initializeInfo.model) || undefined, + }; +} + +/** Records the merged, aggregated tools/list surface (SS3.3/SS6 step 4). Each tool + * MUST already carry the `server` field that owns it (SS3.3: "tools are recorded with + * their owning server name"). */ +function recordToolsList(session, tools) { + if (session.finalized) throw fail("Cannot record into a finalized session", "SESSION_FINALIZED"); + if (!Array.isArray(tools)) throw fail("tools must be an array", "INVALID_ARGUMENT"); + session.tools = tools.map((t) => ({ name: t.name, server: t.server, schema: t.schema })); +} + +/** Records the outgoing half of a tools/call (or sampling/createMessage) request, + * keyed by JSON-RPC id (SS3.3's concurrency requirement). `isModelCall` must be decided + * by the caller at the protocol level (method name), never guessed from the tool name + * (SS3.3, last paragraph). */ +function recordCallStart(session, jsonRpcId, call) { + if (session.finalized) throw fail("Cannot record into a finalized session", "SESSION_FINALIZED"); + if (jsonRpcId === undefined || jsonRpcId === null) { + throw fail("jsonRpcId is required to correlate a pending call", "INVALID_ARGUMENT"); + } + if (session.pendingCalls.has(jsonRpcId)) { + throw fail(`Duplicate in-flight JSON-RPC id ${JSON.stringify(jsonRpcId)} on this connection`, "DUPLICATE_JSONRPC_ID"); + } + session.pendingCalls.set(jsonRpcId, { + tool: call.tool, + server: call.server, + arguments: call.arguments, + model_call: Boolean(call.isModelCall), + ts: call.ts !== undefined ? call.ts : Date.now(), + /* Reserves this call's position in invocation order (SS3.3) at the moment it + * STARTS, not when its response happens to arrive. Concurrent calls can complete + * in any order (the whole point of id-based correlation), but the persisted + * execution_trace's step numbers must reflect when each call was actually + * invoked -- see toSealableSession's sort by this field. */ + seq: session.nextCallSeq++, + }); +} + +/** Records the response half, correlating strictly by JSON-RPC id. A response for an id + * that was never sent (protocol violation from a misbehaving downstream, or a bug) is + * recorded as an anomaly and does NOT crash the proxy and is NOT attributed to any real + * pending call (SS3.3, test plan item 11). Returns true if correlated, false if recorded + * as an anomaly. */ +function recordCallResult(session, jsonRpcId, result) { + if (session.finalized) throw fail("Cannot record into a finalized session", "SESSION_FINALIZED"); + const pending = session.pendingCalls.get(jsonRpcId); + if (!pending) { + session.anomalies.push({ + kind: "UNMATCHED_RESPONSE", + jsonRpcId, + detail: "response arrived for a JSON-RPC id with no matching pending call", + ts: result && result.ts !== undefined ? result.ts : Date.now(), + }); + return false; + } + session.pendingCalls.delete(jsonRpcId); + const callResult = result ? result.result : undefined; + session.calls.push({ + tool: pending.tool, + server: pending.server, + arguments: pending.arguments, + result: callResult, + isError: Boolean(result && result.isError), + /* PR #29 review thread 4000335147, frontier-panel review (4/4 converged) "flag + * structurally malformed tools/call results distinctly from tool-level errors": + * `malformedResult` is the caller's (proxy.js) own pure, `tools/call`-only, + * transport-success-only judgement (see isMalformedToolCallResult) of whether + * `callResult` itself is even a recognizable CallToolResult shape -- this module + * stores it verbatim, exactly like `isError`, and does not itself compute or + * re-derive it (this module has no opinion on MCP result shapes, by design -- see + * this file's header). Always Boolean-coerced so an absent field (every non- + * tools/call caller, e.g. a sampling/createMessage result) stores a plain `false`, + * never `undefined`, keeping this an audit-only annotation that never needs a + * caller to opt in. */ + malformedResult: Boolean(result && result.malformedResult), + model_call: pending.model_call, + ts: pending.ts, + seq: pending.seq, + }); + /* Re-triage fix: MAX_SESSION_CALL_BYTES (proxy.js) needs a running total of what this + * session has actually retained -- see payloadByteSize's own comment above. */ + session.totalCallBytes += payloadByteSize(pending.arguments) + payloadByteSize(callResult); + return true; +} + +/* CodeRabbit/Codex PR #29 review round 8 "keep unmatched downstream IDs out of agent + * correlation": a fixed, non-speculative cap on session.anomalies, matching the same + * discipline as MAX_PENDING_CALLS_PER_SESSION/MAX_COMPLETED_CALLS_PER_SESSION (proxy.js). + * Without one, a misbehaving or compromised downstream emitting a stream of responses + * with unknown ids could grow this array (and the eventual sealed bundle) without bound, + * independently of both call-history caps -- neither of which this ever went through. */ +const MAX_ANOMALIES_PER_SESSION = 1000; + +/** Records a downstream response that could not be correlated to any pending call, + * WITHOUT going through recordCallResult's agent-facing pendingCalls lookup (CodeRabbit + * PR #29 review round 8 "record unmatched downstream responses without agent-call + * correlation" / Codex PR #29 review round 8 "keep unmatched downstream IDs out of agent + * correlation"). The id on an unmatched DOWNSTREAM response is the downstream leg's own + * internal id (assigned by scripts/gateway/downstream.js), not an agent-facing JSON-RPC + * id -- recordCallResult's pendingCalls Map is keyed by the latter. Feeding a + * downstream-internal id into recordCallResult risked colliding with an unrelated LIVE + * agent call that happens to share the same id value: recordCallResult would delete that + * live pending call and record this stale/foreign response as its result, corrupting the + * session trace and losing the UNMATCHED_RESPONSE anomaly entirely. This function only + * ever appends the anomaly -- it never touches pendingCalls. + * + * Capped at MAX_ANOMALIES_PER_SESSION (Codex PR #29 review round 8 "cap unmatched-response + * anomalies per session"): once reached, further anomalies are dropped rather than grow + * session.anomalies without bound, but the cap being hit is itself recorded once (a single + * terminal marker entry) so it leaves a trace rather than silently truncating. */ +function recordUnmatchedResponseAnomaly(session, jsonRpcId, detail, ts) { + if (session.finalized) throw fail("Cannot record into a finalized session", "SESSION_FINALIZED"); + if (session.anomalies.length >= MAX_ANOMALIES_PER_SESSION) return false; + session.anomalies.push({ + kind: "UNMATCHED_RESPONSE", + jsonRpcId, + detail: detail || "response arrived for a JSON-RPC id with no matching pending call", + ts: ts !== undefined ? ts : Date.now(), + }); + if (session.anomalies.length >= MAX_ANOMALIES_PER_SESSION) { + session.anomalies.push({ + kind: "ANOMALY_CAP_REACHED", + detail: `This session reached the ${MAX_ANOMALIES_PER_SESSION}-anomaly cap -- further anomalies are dropped, not recorded.`, + ts: ts !== undefined ? ts : Date.now(), + }); + } + return true; +} + +/** Called when the downstream side of a connection disconnects (or the whole session is + * finalized) with calls still pending: each is recorded with an explicit disconnect + * marker, never silently dropped (SS7 failure mode / test plan item 10). + * + * `serverFilter`, when given, scopes this to only the pending calls whose recorded + * `server` matches it -- a downstream disconnect must not corrupt the attestation of a + * call pending against a different, still-healthy downstream. Omitted entirely (the + * connection-close / full-finalize callers) means "every pending call on this session", + * as before. + * + * `onDisconnect(call, at)`, when given, is invoked once per call actually moved into + * `session.calls` here (Codex PR #29 review round 4 "log calls finalized as + * disconnected": a call finalized this way previously got no matching + * "gateway_call_completed" log line at all -- neither here nor later, since + * handleMessage() only logs a call it itself still finds pending -- leaving disconnected + * steps with no run ID, status, or duration in the operational log even though they are + * fully recorded in the persisted trace). This module has no logger of its own by design + * (see its header); the caller decides what, if anything, to log. */ +function markPendingAsDisconnected(session, reason, now, serverFilter, onDisconnect) { + const at = typeof now === "function" ? now() : Date.now(); + for (const [jsonRpcId, pending] of session.pendingCalls.entries()) { + if (serverFilter !== undefined && pending.server !== serverFilter) continue; + const call = { + tool: pending.tool, + server: pending.server, + arguments: pending.arguments, + result: null, + isError: true, + model_call: pending.model_call, + ts: pending.ts, + seq: pending.seq, + disconnected: true, + disconnect_reason: reason || "downstream disconnected", + jsonRpcId, + }; + session.calls.push(call); + session.pendingCalls.delete(jsonRpcId); + /* Re-triage fix: a disconnected call still retains its request params in + * session.calls (its result is always null) -- count it the same as an ordinarily + * completed call so the byte cap cannot be bypassed via repeated disconnects. */ + session.totalCallBytes += payloadByteSize(call.arguments); + /* CodeRabbit PR #29 review round 4 "contain disconnect-callback failures during + * session finalization": onDisconnect is the caller's own side effect (proxy.js + * wires it to a JSON.stringify + this.log() call) and this module deliberately has + * no logger of its own (see header) to report a failure in it -- but letting such a + * failure escape uncaught is far worse than losing that one log line: it would abort + * this loop (leaving any REMAINING pending calls on this session never marked + * disconnected), and propagate up through closeConnection/handleDownstreamDisconnect, + * skipping session removal, finalizeSession, and chain.appendSession entirely. Inside + * gateway.js's shutdown drain loop that uncaught throw would also abort finalizing + * every OTHER open session and skip writerClaim.release(), leaking a stale + * writer-claim that blocks the next gateway start. A failed logging side effect must + * never take down session bookkeeping or shutdown with it. */ + if (typeof onDisconnect === "function") { + try { + onDisconnect(call, at); + } catch (error) { + // Swallowed deliberately -- see the comment above. This module has no logger to + // report it to, and the call itself is already correctly recorded in + // session.calls above regardless of whether the caller's side effect succeeded. + } + } + } +} + +/** Projects the internal session record into the shape sealBoundaryBundle expects + * (SS5.2: unchanged, this is gsa-mcp-shim.js's existing contract, not a new schema) -- + * plus two additive, backward-compatible extensions gsa-mcp-shim.js reads when present + * (never required): each call's disconnect marker (so a downstream disconnect or + * unmatched response is distinguishable from an ordinary tool error in the persisted + * bundle, not just in the gateway's own in-memory state) and the session's anomalies. + * + * Calls are sorted by invocation order (`seq`, reserved at recordCallStart) rather than + * left in response-arrival order, so the execution_trace step numbers sealBoundaryBundle + * assigns reflect when each call actually started, even when a later call's response + * arrives first. */ +function toSealableSession(session) { + const orderedCalls = session.calls.slice().sort((a, b) => (a.seq || 0) - (b.seq || 0)); + return { + initialize: session.initialize || {}, + tools: session.tools, + calls: orderedCalls.map((c) => ({ + tool: c.tool, + server: c.server, + arguments: c.arguments, + result: c.result, + isError: c.isError, + /* Additive, backward-compatible field (see recordCallResult's own comment above + * and gsa-mcp-shim.js's sealBoundaryBundle, which folds this into the signed + * `malformed_result` trace field ONLY when true -- a well-formed call's trace line + * is unaffected). Carried through unconditionally here (like isError/model_call) + * because it is now part of every call's base recorded shape, not an optional + * extra like disconnected/jsonRpcId below. */ + malformedResult: c.malformedResult, + model_call: c.model_call, + ts: c.ts, + ...(c.disconnected ? { disconnected: true, disconnect_reason: c.disconnect_reason, jsonRpcId: c.jsonRpcId } : {}), + })), + goal: session.goal, + anomalies: session.anomalies, + samplingNegotiated: Boolean(session.samplingNegotiated), + }; +} + +/** Finalizes a session: fails closed if pendingCalls is non-empty (SS6 step 10 / SS7 -- + * the caller must have already drained/marked-disconnected every pending call before + * calling this; a non-empty pendingCalls map reaching sealBoundaryBundle would silently + * produce an incomplete execution_trace). Calls sealBoundaryBundle UNCHANGED (SS3.5). */ +function finalizeSession(session, keys) { + if (session.finalized) throw fail("Session already finalized", "SESSION_FINALIZED"); + if (session.pendingCalls.size > 0) { + throw fail( + `Refusing to finalize session ${session.connectionId}: ${session.pendingCalls.size} call(s) still ` + + "pending. Every in-flight call must be resolved or marked disconnected before sealing " + + "(see markPendingAsDisconnected) -- sealing a session with pending calls would silently " + + "produce an incomplete execution_trace.", + "SESSION_HAS_PENDING_CALLS" + ); + } + let sealed; + try { + sealed = sealBoundaryBundle(toSealableSession(session), keys); + } catch (error) { + /* SS7: "sealBoundaryBundle throws ... fail-closed ... log the full session state for + * debugging, do not attempt to seal a partial/guessed bundle." The caller (gateway.js) + * is responsible for the actual logging; this wraps the error with the full session + * attached so that logging has something to log. */ + const wrapped = fail(`sealBoundaryBundle threw while finalizing session ${session.connectionId}: ${error.message}`, "SEAL_FAILED"); + wrapped.session = session; + wrapped.cause = error; + throw wrapped; + } + session.finalized = true; + return sealed; +} + +module.exports = { + createSession, + recordInitialize, + recordToolsList, + recordCallStart, + recordCallResult, + recordUnmatchedResponseAnomaly, + MAX_ANOMALIES_PER_SESSION, + markPendingAsDisconnected, + toSealableSession, + finalizeSession, +}; diff --git a/scripts/graphlint.js b/scripts/graphlint.js index 0c07527c..0f39de87 100644 --- a/scripts/graphlint.js +++ b/scripts/graphlint.js @@ -91,7 +91,7 @@ function sanitize(src, isPy) { const e = src.indexOf(c3, i + 3); const end = e === -1 ? n : e + 3; blank(bare, i + 3, end - 3); i = end; continue; } - if (c === "'" || c === '"' || (!isPy && c === "\u0060")) { + if (c === "'" || c === '"' || (!isPy && c === "`")) { const isTemplate = !isPy && c === "`"; // backticks span multiple lines let j = i + 1; while (j < n) { @@ -264,10 +264,18 @@ function lintProject(root) { // R5 — eval/exec/new-require ban (bare view: comments/strings blanked). // Scoped to evolvable targets only (§104): machine-evaluated candidates, // scaffold-generated adapters/, workflow/worker code. Constitutional engine - // scripts (files in graphlint.js's OWN __dirname, the hash-pinned scripts/) - // are exempt — their child_process use is legitimate supervisor infrastructure. - // Resolving the dir guards against spoofing by any nested dir named "scripts". - if (path.resolve(path.dirname(f)) !== __dirname) { + // scripts (files anywhere under graphlint.js's OWN __dirname subtree — + // scripts/ itself and its subdirectories, e.g. scripts/gateway/) are exempt — + // their child_process use is legitimate supervisor infrastructure. + // This is a subtree check (path.relative against the real __dirname), not a + // string match on the literal name "scripts", so it still guards against + // spoofing by any nested directory elsewhere in the tree that happens to be + // named "scripts" (or a subdirectory of one) without actually being under + // graphlint.js's own real __dirname. + const dirRelToScripts = path.relative(__dirname, path.resolve(path.dirname(f))); + const underScriptsDir = dirRelToScripts === "" || + (!dirRelToScripts.startsWith("..") && !path.isAbsolute(dirRelToScripts)); + if (!underScriptsDir) { if (EVAL_RE.test(ln)) add(f, i + 1, "R5: eval() call — generated/evolvable code must never gain new execution surface", "HIGH", "Replace eval() with a static dispatch table or JSON.parse."); diff --git a/scripts/gsa-mcp-shim.js b/scripts/gsa-mcp-shim.js index 0152f447..3cd40c71 100644 --- a/scripts/gsa-mcp-shim.js +++ b/scripts/gsa-mcp-shim.js @@ -21,27 +21,70 @@ function sha256Hex(s) { return crypto.createHash("sha256").update(Buffer.from(St /* session = { * initialize: { clientInfo:{name,version}, serverInfo:{name,version}, model?:string }, * tools: [{ name, server, schema }], // from tools/list — the GRANTED surface - * calls: [{ tool, arguments, result, isError?, model_call?, ts? }], // tools/call sequence - * goal?: string + * calls: [{ tool, arguments, result, isError?, model_call?, ts?, + * disconnected?, disconnect_reason?, jsonRpcId?, malformedResult? }], + * // tools/call sequence + * goal?: string, + * anomalies?: [{ kind, jsonRpcId, detail, ts }] // e.g. UNMATCHED_RESPONSE (session.js) * } - * keys = { privateKey, signer, algo } */ + * keys = { privateKey, signer, algo } + * + * `disconnected`/`disconnect_reason`/`jsonRpcId` on a call and the session-level + * `anomalies` array are additive, optional fields (gateway callers only -- see + * scripts/gateway/session.js#toSealableSession): when present they are folded into the + * persisted record below so an operator can tell a downstream disconnect or unmatched + * response apart from an ordinary tool error from the sealed bundle alone, not only from + * the gateway's own in-memory state. Never required, and never changes the bundle's own + * schema (schemas/attestation-bundle.schema.json) -- both land inside existing free-form + * artifact bodies (execution_trace.jsonl lines, decision_record.md text). */ function sealBoundaryBundle(session, keys) { if (!session || typeof session !== "object") throw new Error("sealBoundaryBundle: session required"); const init = session.initialize || {}; const tools = Array.isArray(session.tools) ? session.tools : []; const calls = Array.isArray(session.calls) ? session.calls : []; + const anomalies = Array.isArray(session.anomalies) ? session.anomalies : []; const grantedTools = tools.map((t) => (t.server ? t.server + ":" : "") + t.name); - // execution_trace: one entry per tool call; a call to a tool NOT in the granted surface is flagged. + /* Codex PR #29 review "treat sampling as a negotiated client capability" (comment + * 4000335132): a model_call (sampling/createMessage) is not a tools/call -- its id + * (e.g. "fs:sampling/createMessage") can never appear in grantedTools (the tools/list + * surface), so checking it there always read as "not granted" in the signed record even + * when sampling was genuinely negotiated and the call genuinely authorized. Sampling is + * its own MCP capability, negotiated once per connection at initialize -- session. + * samplingNegotiated (set by GatewayProxy#openConnection from gateway.js's own + * agentTransportSupportsSampling, the same fact that already gates whether this gateway + * advertises `capabilities: { sampling: {} }` to a downstream and whether + * forwardDownstreamRequestToAgent will relay a call at all) is the honest signal for it. */ + const samplingNegotiated = Boolean(session.samplingNegotiated); + // execution_trace: one entry per tool call; a call to a tool NOT in the granted surface is flagged + // (tools/call), or one whose connection never negotiated sampling is flagged (model_call). const traceLines = calls.map((c, i) => { const id = (c.server ? c.server + ":" : "") + c.tool; return JSON.stringify({ step: i + 1, kind: "mcp_tool_call", tool: id, - granted: grantedTools.indexOf(id) !== -1, // requested ⊆ granted signal + granted: c.model_call ? samplingNegotiated : grantedTools.indexOf(id) !== -1, input_sha256: sha256Hex(JSON.stringify(c.arguments === undefined ? null : c.arguments)), result_sha256: sha256Hex(JSON.stringify(c.result === undefined ? null : c.result)), is_error: !!c.isError, model_call: !!c.model_call, // sampling/createMessage → non-deterministic + ...(c.disconnected ? { disconnected: true, disconnect_reason: c.disconnect_reason || null } : {}), + ...(c.jsonRpcId !== undefined ? { jsonRpcId: c.jsonRpcId } : {}), + /* PR #29 review thread 4000335147, frontier-panel review (4/4 converged) "flag + * structurally malformed tools/call results distinctly from tool-level errors": + * a THIRD state alongside is_error/model_call above -- the downstream answered, + * but not in any shape the MCP CallToolResult contract recognizes (see proxy.js's + * isMalformedToolCallResult for the exact predicate). Purely an audit annotation + * on the signed trace, same discipline as disconnected/jsonRpcId immediately + * above: additive and OMITTED (not `false`) when the call was well-formed, so a + * clean call's trace line -- and its hash, for anything that hashes trace lines -- + * is byte-for-byte identical to what it was before this field existed. Never + * asserted for a model_call (sampling/createMessage is not a tools/call and this + * predicate is never computed for one -- see proxy.js) or for a transport failure + * (own unrelated failure shape, judged before this predicate ever runs). This is + * NOT full CallToolResult validation -- per-content-block schema conformance is + * explicitly out of scope; it only asks whether the result carries ANY recognizable + * success/error shape at all. */ + ...(c.malformedResult ? { malformed_result: true } : {}), }); }); const outputs = calls.filter((c) => !c.isError).map((c, i) => ({ call: i + 1, result_sha256: sha256Hex(JSON.stringify(c.result === undefined ? null : c.result)) })); @@ -62,7 +105,7 @@ function sealBoundaryBundle(session, keys) { validation_report: A("validation_report.json", JSON.stringify({ boundary: BOUNDARY })), execution_trace: A("execution_trace.jsonl", traceLines.join("\n")), output_manifest: A("output_manifest.json", JSON.stringify({ outputs })), - decision_record: A("decision_record.md", "# MCP boundary attestation\n\nTool I/O at the MCP boundary only. The agent's plan/compiled graph was not observed (no agent changes were made). Non-MCP side effects are NOT captured. This bundle asserts profile A (boundary), never the full plan profiles."), + decision_record: A("decision_record.md", "# MCP boundary attestation\n\nTool I/O at the MCP boundary only. The agent's plan/compiled graph was not observed (no agent changes were made). Non-MCP side effects are NOT captured. This bundle asserts profile A (boundary), never the full plan profiles." + (anomalies.length > 0 ? "\n\n## Anomalies\n\n" + anomalies.map((a) => `- ${a.kind || "unknown"}: ${a.detail || ""} (jsonRpcId=${JSON.stringify(a.jsonRpcId)}, ts=${a.ts})`).join("\n") : "")), }, // Each MCP tool becomes a skill entry with provenance = hash of its declared schema. MCP tools are // observed, not GraphSmith-approved — so all_skills_signed_and_approved honestly recomputes false. diff --git a/tests/gateway/_fixtures/fixture-mcp-server.js b/tests/gateway/_fixtures/fixture-mcp-server.js new file mode 100644 index 00000000..63f36e4c --- /dev/null +++ b/tests/gateway/_fixtures/fixture-mcp-server.js @@ -0,0 +1,112 @@ +#!/usr/bin/env node +/* Minimal stdio JSON-RPC test fixture standing in for "a real downstream MCP server" + * (Standalone Gateway TRD SS8's test plan needs one that is NOT the gateway's own code, + * to prove the proxy/correlation logic against an independent implementation). Exposes + * three tools: "echo" (returns { echoed: arguments }), "boom" (always returns an + * error), and "sample" (sends its OWN unsolicited "sampling/createMessage" request back + * up this same stdio connection and resolves the tool call with whatever the agent's + * model returns -- the fixture-side half of the board-decision 2026-09-04 "forward + * downstream sampling requests upstream" fix, tests/gateway/e2e's own suite drives the + * gateway-side half). Also supports a deliberate response DELAY via arguments.delayMs on + * "echo"/"boom", used by out-of-order-correlation tests. Speaks the same + * newline-delimited JSON-RPC framing mcp-server/src/stdioTransport.js and + * scripts/gateway/downstream.js both use. + * + * Usage: node fixture-mcp-server.js [--server-name NAME] + */ +"use strict"; + +const readline = require("readline"); + +const nameIndex = process.argv.indexOf("--server-name"); +const serverName = nameIndex !== -1 ? process.argv[nameIndex + 1] : "fixture"; + +const rl = readline.createInterface({ input: process.stdin, terminal: false }); + +function send(msg) { + process.stdout.write(JSON.stringify(msg) + "\n"); +} + +function ok(id, result) { + send({ jsonrpc: "2.0", id, result }); +} +function err(id, message) { + send({ jsonrpc: "2.0", id, error: { code: -32000, message } }); +} + +// Requests THIS fixture itself sent upstream (sampling/createMessage), awaiting a reply +// -- distinct from `id`s the caller assigns to requests it sends TO this fixture. +const pendingUpstream = new Map(); +let nextUpstreamId = 1; + +rl.on("line", (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + let msg; + try { + msg = JSON.parse(trimmed); + } catch (error) { + return; + } + const { id, method, params } = msg; + + // A reply to this fixture's own upstream sampling/createMessage request, not a new + // request directed at this fixture (no "method" -- a real request always has one). + if (typeof method !== "string" && id !== undefined && id !== null && pendingUpstream.has(id)) { + const { onReply } = pendingUpstream.get(id); + pendingUpstream.delete(id); + onReply(msg); + return; + } + + if (method === "initialize") { + ok(id, { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: serverName, version: "1.0.0-fixture" } }); + return; + } + if (method === "tools/list") { + ok(id, { + tools: [ + { name: `${serverName}_echo`, description: "echoes its arguments", inputSchema: { type: "object" } }, + { name: `${serverName}_boom`, description: "always errors", inputSchema: { type: "object" } }, + { name: `${serverName}_sample`, description: "asks the connected agent's model to do inference, via an unsolicited sampling/createMessage this fixture sends upstream", inputSchema: { type: "object" } }, + ], + }); + return; + } + if (method === "tools/call") { + const toolName = params && params.name; + const args = (params && params.arguments) || {}; + if (toolName === `${serverName}_sample`) { + const upstreamId = "fixture-sample-" + nextUpstreamId++; + pendingUpstream.set(upstreamId, { + onReply: (reply) => { + if (reply.error) err(id, `sampling forward failed: ${reply.error.message}`); + else ok(id, { content: [{ type: "text", text: JSON.stringify({ sampled: reply.result }) }], isError: false }); + }, + }); + send({ jsonrpc: "2.0", id: upstreamId, method: "sampling/createMessage", params: { prompt: args.prompt || "say hi" } }); + return; + } + const respond = () => { + if (toolName === `${serverName}_boom`) { + err(id, "boom: this tool always fails"); + } else { + ok(id, { content: [{ type: "text", text: JSON.stringify({ echoed: args }) }], isError: false }); + } + }; + if (typeof args.delayMs === "number" && args.delayMs > 0) { + setTimeout(respond, args.delayMs); + } else { + respond(); + } + return; + } + // CodeRabbit PR #29 review "do not answer notifications/initialized with an error + // response": a notification (no "id") must never get a reply per JSON-RPC/MCP, even an + // error one -- err(undefined, ...) below drops the id key from the JSON entirely + // (JSON.stringify omits an undefined property), which downstream.js's real client + // silently ignores today (harmless in practice), but this fixture stands in for "an + // independent, spec-conforming downstream" and should not itself violate the spec. + if (id === undefined || id === null) return; + err(id, `Unknown method: ${method}`); +}); diff --git a/tests/gateway/_fixtures/mode-file.js b/tests/gateway/_fixtures/mode-file.js new file mode 100644 index 00000000..918a2691 --- /dev/null +++ b/tests/gateway/_fixtures/mode-file.js @@ -0,0 +1,37 @@ +"use strict"; +/* Shared test helper: writes a valid (or deliberately broken) .graphsmith/gateway-mode.json + * + .graphsmith/state/gateway-mode.key pair into a fresh root directory, using the SAME + * HMAC formula scripts/gateway/mode-gate.js verifies against -- this stands in for the + * mode-selection contract's own confirmation CLI (Track 1.1's `graphsmith gateway mode + * set`, scripts/mode-selection.js's writeGatewayMode/ensureSecret), now landed in this + * repo. The deployment secret is written as a hex-encoded STRING (not raw bytes) because + * that is the real, observable format ensureSecret() produces + * (crypto.randomBytes(32).toString("hex")) and readSecret() expects (utf8 + trim) -- + * this fixture previously wrote raw random bytes, which only worked against mode-gate.js's + * OLD private reader (an unencoded Buffer read with no format assumption); it does not + * match the real confirmation CLI's on-disk format and was updated to match. */ + +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); +const { computeConfirmationToken } = require("../../../scripts/gateway/mode-gate.js"); + +function writeConfirmedMode(root, mode, options = {}) { + fs.mkdirSync(path.join(root, ".graphsmith", "state"), { recursive: true }); + const secret = options.secret || crypto.randomBytes(32).toString("hex"); + fs.writeFileSync(path.join(root, ".graphsmith", "state", "gateway-mode.key"), secret, "utf8"); + const record = { + schema_version: "1.0", + mode, + confirmation: { + confirmed_by: options.confirmedBy || "test-operator@example.com", + confirmed_at: options.confirmedAt !== undefined ? options.confirmedAt : Date.now(), + nonce: options.nonce || crypto.randomBytes(16).toString("hex"), + }, + }; + record.confirmation.confirmation_token = options.badToken || computeConfirmationToken(secret, record); + fs.writeFileSync(path.join(root, ".graphsmith", "gateway-mode.json"), JSON.stringify(record, null, 2)); + return record; +} + +module.exports = { writeConfirmedMode }; diff --git a/tests/gateway/chain/run-tests.js b/tests/gateway/chain/run-tests.js new file mode 100644 index 00000000..90934d5c --- /dev/null +++ b/tests/gateway/chain/run-tests.js @@ -0,0 +1,204 @@ +#!/usr/bin/env node +"use strict"; + +/* Regression suite for scripts/gateway/chain.js (SG-FR-5) + checks/register-gateway- + * sessions.js (SG-FR-7). Covers Standalone Gateway TRD SS8 test plan items: + * 7 ten sessions in sequence -> chain.jsonl has 10 correctly-linked entries, HEAD.json + * matches the tenth, the verifier reports the chain intact. + * 8 a real, untouched chain from test fixture data verifies successfully (not only + * self-generated data). + * 12 process killed mid-append (between the bundle write and HEAD.json update) -> + * restart's verifier detects the incomplete append and reports it explicitly. + * 16 a middle chain entry is mutated -> verifier fails closed, names the broken link. + * 17 a chain entry is deleted entirely (sequence gap) -> detected and reported, + * DISTINCT from a broken-hash-link failure. + */ + +const crypto = require("crypto"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const ROOT = path.resolve(__dirname, "../../.."); +const chain = require(path.join(ROOT, "scripts", "gateway", "chain.js")); +const session = require(path.join(ROOT, "scripts", "gateway", "session.js")); +const { walkGatewaySessions } = require(path.join(ROOT, "checks", "register-gateway-sessions.js")); + +let failures = 0; +const results = []; +function record(name, status, reason) { + console.log(status === "PASS" ? `PASS ${name}` : `FAIL ${name}+${reason || "unknown"}`); + results.push({ name, status, reason: reason || "" }); + if (status === "FAIL") failures++; +} +function check(name, cond, reason) { + record(name, cond ? "PASS" : "FAIL", reason); +} + +function freshDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), `gs-gateway-chain-${prefix}-`)); +} +function makeKeys() { + const kp = crypto.generateKeyPairSync("ed25519"); + return { privateKey: kp.privateKey, signer: "test-key", algo: "ed25519" }; +} +function sealTrivialSession(connectionId, keys) { + const s = session.createSession(connectionId); + /* gsa-mcp-shim.js's bundle_id is a deterministic hash of {init, grantedTools, n}, not + * of connectionId -- two "trivial" sessions with identical initialize/tools/calls + * content would otherwise collide on bundle_id (a real, useful property being + * exercised here: content-addressing means genuinely-identical sessions ARE the same + * bundle). Varying clientInfo.name by connectionId gives each test session distinct + * content so this suite can create many independent chain entries. */ + session.recordInitialize(s, { clientInfo: { name: `test-agent-${connectionId}`, version: "1.0" } }); + session.recordToolsList(s, []); + return session.finalizeSession(s, keys); +} +function verifyDir(dir, extra) { + return walkGatewaySessions({ + chain: chain.readChain(dir), + head: chain.readHead(dir), + computeEntrySha256: chain.computeEntrySha256, + bundleExists: (id) => fs.existsSync(chain.bundlePath(dir, id)), + ...extra, + }); +} + +/* SS8 test 7 */ +function tenSessionsInSequence() { + const dir = freshDir("ten-sessions"); + const keys = makeKeys(); + const entries = []; + for (let i = 0; i < 10; i++) { + const sealed = sealTrivialSession(`conn-${i}`, keys); + entries.push(chain.appendSession(dir, sealed)); + } + check("ten-sessions-seq-1-through-10", entries.every((e, i) => e.seq === i + 1), JSON.stringify(entries.map((e) => e.seq))); + check("ten-sessions-each-links-to-prior", entries.every((e, i) => i === 0 ? e.prev_entry_sha256 === null : e.prev_entry_sha256 === entries[i - 1].entry_sha256), "link chain broken"); + const head = chain.readHead(dir); + check("head-matches-tenth-entry", head.seq === 10 && head.entry_sha256 === entries[9].entry_sha256, JSON.stringify(head)); + const result = verifyDir(dir); + check("verifier-reports-chain-intact", result.status === "verified", JSON.stringify(result)); +} + +/* SS8 test 8: fixture data generated in ONE process/run, verified in a completely + * separate call -- not the same in-memory objects the append path just produced. */ +function fixtureDataVerifiesIndependently() { + const dir = freshDir("fixture"); + const keys = makeKeys(); + for (let i = 0; i < 3; i++) chain.appendSession(dir, sealTrivialSession(`fixture-conn-${i}`, keys)); + // Re-read everything fresh from disk, as an independent verifier run would. + const freshChain = JSON.parse(`[${fs.readFileSync(chain.chainPath(dir), "utf8").trim().split("\n").join(",")}]`); + const freshHead = JSON.parse(fs.readFileSync(chain.headPath(dir), "utf8")); + const result = walkGatewaySessions({ + chain: freshChain, + head: freshHead, + computeEntrySha256: chain.computeEntrySha256, + bundleExists: (id) => fs.existsSync(chain.bundlePath(dir, id)), + }); + check("independently-reread-fixture-verifies", result.status === "verified", JSON.stringify(result)); +} + +/* SS8 test 12: simulate a crash between the chain.jsonl append and the HEAD.json update + * -- append the entry to chain.jsonl but do NOT update HEAD.json (this is exactly the + * on-disk state a kill between those two steps leaves, per SS3.6's write ordering). */ +function incompleteAppendDetected() { + const dir = freshDir("incomplete-append"); + const keys = makeKeys(); + const entry1 = chain.appendSession(dir, sealTrivialSession("conn-a", keys)); + // Second session: write bundle + chain entry manually, WITHOUT updating HEAD.json, + // to simulate the crash window. + const sealed2 = sealTrivialSession("conn-b", keys); + const bundleId2 = sealed2.bundle.manifest.bundle_id; + fs.writeFileSync(chain.bundlePath(dir, bundleId2), JSON.stringify(sealed2.bundle)); + const partial = { schema_version: "1.0", seq: 2, bundle_id: bundleId2, prev_entry_sha256: entry1.entry_sha256 }; + const entry2 = { ...partial, entry_sha256: chain.computeEntrySha256(partial) }; + fs.appendFileSync(chain.chainPath(dir), JSON.stringify(entry2) + "\n"); + // HEAD.json still points at entry1 -- exactly the "stale head" incomplete-append shape. + + const result = verifyDir(dir); + check("incomplete-append-detected-as-failed", result.status === "failed", JSON.stringify(result)); + check("incomplete-append-reason-names-incomplete-append", /incomplete append/.test(result.reason || ""), JSON.stringify(result)); +} + +/* SS8 test 16: mutate a middle entry's entry_sha256. */ +function mutatedEntryDetected() { + const dir = freshDir("mutated"); + const keys = makeKeys(); + for (let i = 0; i < 3; i++) chain.appendSession(dir, sealTrivialSession(`conn-${i}`, keys)); + const lines = fs.readFileSync(chain.chainPath(dir), "utf8").trim().split("\n").map((l) => JSON.parse(l)); + lines[1].entry_sha256 = "f".repeat(64); // mutate the middle entry + fs.writeFileSync(chain.chainPath(dir), lines.map((l) => JSON.stringify(l)).join("\n") + "\n"); + + const result = verifyDir(dir); + check("mutated-middle-entry-fails-closed", result.status === "failed", JSON.stringify(result)); + check("mutated-entry-reason-names-tampered", /TAMPERED/.test(result.reason || ""), JSON.stringify(result)); +} + +/* SS8 test 17: delete an entire entry (sequence gap), distinct failure text from a + * mutated/broken-link failure. */ +function deletedEntryDetectedDistinctly() { + const dir = freshDir("deleted"); + const keys = makeKeys(); + for (let i = 0; i < 5; i++) chain.appendSession(dir, sealTrivialSession(`conn-${i}`, keys)); + const lines = fs.readFileSync(chain.chainPath(dir), "utf8").trim().split("\n").map((l) => JSON.parse(l)); + lines.splice(2, 1); // delete seq=3 entirely -> gap 2, 4, 5 + fs.writeFileSync(chain.chainPath(dir), lines.map((l) => JSON.stringify(l)).join("\n") + "\n"); + + const result = walkGatewaySessions({ chain: lines, head: null, computeEntrySha256: chain.computeEntrySha256 }); + check("deleted-entry-detected-as-failed", result.status === "failed", JSON.stringify(result)); + check("deleted-entry-reason-names-sequence-gap-distinctly", /SEQUENCE GAP/.test(result.reason || "") && !/TAMPERED/.test(result.reason || ""), JSON.stringify(result)); +} + +/* Board decision 2026-09-04, PR #29 review "require the verified chain to start at + * sequence one": a chain whose surviving first entry has seq > 1 but an otherwise + * self-consistent (recomputable, null-predecessor) hash must still be rejected -- + * distinct from deletedEntryDetectedDistinctly above, which deletes a MIDDLE entry and + * leaves a real seq gap; this forges the FIRST entry to look like a legitimate root. */ +function missingGenesisPrefixDetected() { + const dir = freshDir("missing-genesis"); + const keys = makeKeys(); + for (let i = 0; i < 2; i++) chain.appendSession(dir, sealTrivialSession(`conn-${i}`, keys)); + const lines = fs.readFileSync(chain.chainPath(dir), "utf8").trim().split("\n").map((l) => JSON.parse(l)); + const forged = { ...lines[1], prev_entry_sha256: null }; + forged.entry_sha256 = chain.computeEntrySha256({ schema_version: forged.schema_version, seq: forged.seq, bundle_id: forged.bundle_id, prev_entry_sha256: forged.prev_entry_sha256 }); + + const result = walkGatewaySessions({ chain: [forged], head: null, computeEntrySha256: chain.computeEntrySha256 }); + check("missing-genesis-prefix-detected-as-failed", result.status === "failed", JSON.stringify(result)); + check("missing-genesis-prefix-reason-names-seq-not-one", /seq=2, expected 1/.test(result.reason || ""), JSON.stringify(result)); +} + +function bundleIdCollisionRefused() { + const dir = freshDir("collision"); + const keys = makeKeys(); + const sealed = sealTrivialSession("conn-x", keys); + chain.appendSession(dir, sealed); + let threw = null; + try { chain.appendSession(dir, sealed); } catch (error) { threw = error; } // same bundle_id (deterministic from session content) + check("bundle-id-collision-refused", threw && threw.code === "GATEWAY_BUNDLE_ID_COLLISION", threw && threw.code); +} + +function emptyChainIsNotApplicable() { + const dir = freshDir("empty"); + fs.mkdirSync(dir, { recursive: true }); + const result = verifyDir(dir); + check("empty-chain-not-applicable", result.status === "not-applicable", JSON.stringify(result)); +} + +function main() { + tenSessionsInSequence(); + fixtureDataVerifiesIndependently(); + incompleteAppendDetected(); + mutatedEntryDetected(); + deletedEntryDetectedDistinctly(); + missingGenesisPrefixDetected(); + bundleIdCollisionRefused(); + emptyChainIsNotApplicable(); + + const passed = results.filter((r) => r.status === "PASS").length; + const failed = results.filter((r) => r.status === "FAIL").length; + console.log(`SUMMARY passed=${passed} failed=${failed} skipped=0`); + process.exit(failures ? 1 : 0); +} + +main(); diff --git a/tests/gateway/config/run-tests.js b/tests/gateway/config/run-tests.js new file mode 100644 index 00000000..56ec0f57 --- /dev/null +++ b/tests/gateway/config/run-tests.js @@ -0,0 +1,230 @@ +#!/usr/bin/env node +"use strict"; + +/* Regression suite for scripts/gateway/config.js against schemas/gateway-config.schema.json + * (Standalone Gateway TRD SS4). */ + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const ROOT = path.resolve(__dirname, "../../.."); +const gatewayConfig = require(path.join(ROOT, "scripts", "gateway", "config.js")); + +let failures = 0; +const results = []; +function record(name, status, reason) { + console.log(status === "PASS" ? `PASS ${name}` : `FAIL ${name}+${reason || "unknown"}`); + results.push({ name, status, reason: reason || "" }); + if (status === "FAIL") failures++; +} +function check(name, cond, reason) { + record(name, cond ? "PASS" : "FAIL", reason); +} + +function freshFile(prefix, content) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gs-gateway-config-${prefix}-`)); + const target = path.join(dir, "gateway-config.json"); + fs.writeFileSync(target, typeof content === "string" ? content : JSON.stringify(content)); + return target; +} + +const VALID = { + schema_version: "1.0", + state_dir: "/tmp/some-state-dir", + downstream_servers: [{ name: "fs", transport: "stdio", endpoint: "node fixture.js" }], + signing_key_ref: "GRAPHSMITH_SIGNING_KEY", +}; + +function validConfigLoads() { + const p = freshFile("valid", VALID); + let threw = null; + let loaded = null; + try { loaded = gatewayConfig.loadConfig(p); } catch (error) { threw = error; } + check("valid-config-loads-without-throwing", threw === null, threw && threw.message); + check("valid-config-round-trips-state-dir", loaded && loaded.state_dir === VALID.state_dir, "state_dir mismatch"); +} + +function missingRequiredField() { + const bad = { ...VALID }; + delete bad.signing_key_ref; + const p = freshFile("missing-required", bad); + let threw = null; + try { gatewayConfig.loadConfig(p); } catch (error) { threw = error; } + check("missing-signing-key-ref-rejected", threw && threw.code === "GATEWAY_CONFIG_INVALID" && /signing_key_ref is required/.test(threw.message), threw && threw.message); +} + +function additionalPropertyRejected() { + const bad = { ...VALID, mode: "standalone" }; // exactly the Rev-2 mistake the TRD's own header warns against + const p = freshFile("extra-prop", bad); + let threw = null; + try { gatewayConfig.loadConfig(p); } catch (error) { threw = error; } + check("mode-field-rejected-as-additional-property", threw && threw.code === "GATEWAY_CONFIG_INVALID" && /mode is not allowed/.test(threw.message), threw && threw.message); +} + +function badDownstreamTransportRejected() { + const bad = { ...VALID, downstream_servers: [{ name: "x", transport: "carrier-pigeon", endpoint: "y" }] }; + const p = freshFile("bad-transport", bad); + let threw = null; + try { gatewayConfig.loadConfig(p); } catch (error) { threw = error; } + check("bad-downstream-transport-rejected", threw && /transport must be "stdio" or "http"/.test(threw.message), threw && threw.message); +} + +function emptyDownstreamServersRejected() { + const bad = { ...VALID, downstream_servers: [] }; + const p = freshFile("empty-downstream", bad); + let threw = null; + try { gatewayConfig.loadConfig(p); } catch (error) { threw = error; } + check("empty-downstream-servers-rejected", threw && /non-empty array/.test(threw.message), threw && threw.message); +} + +function duplicateDownstreamNameRejected() { + const bad = { ...VALID, downstream_servers: [{ name: "x", transport: "stdio", endpoint: "a" }, { name: "x", transport: "stdio", endpoint: "b" }] }; + const p = freshFile("dup-name", bad); + let threw = null; + try { gatewayConfig.loadConfig(p); } catch (error) { threw = error; } + check("duplicate-downstream-name-rejected", threw && /duplicate name/.test(threw.message), threw && threw.message); +} + +/** Board decision 2026-09-04, PR #29 review "speak authenticated MCP over downstream + * HTTP": downstream_servers entries may now optionally carry a token_ref, resolved the + * same way signing_key_ref/agent_listen.token_ref already are. */ +function downstreamTokenRefAccepted() { + const good = { ...VALID, downstream_servers: [{ name: "auth-fs", transport: "http", endpoint: "http://localhost:1/mcp", token_ref: "GS_TEST_DOWNSTREAM_TOKEN" }] }; + const p = freshFile("downstream-token-ref-ok", good); + let threw = null; + try { gatewayConfig.loadConfig(p); } catch (error) { threw = error; } + check("downstream-token-ref-accepted", threw === null, threw && threw.message); +} + +function downstreamTokenRefMustBeNonEmptyString() { + const bad = { ...VALID, downstream_servers: [{ name: "auth-fs", transport: "http", endpoint: "http://localhost:1/mcp", token_ref: "" }] }; + const p = freshFile("downstream-token-ref-empty", bad); + let threw = null; + try { gatewayConfig.loadConfig(p); } catch (error) { threw = error; } + check("downstream-token-ref-empty-string-rejected", threw && /token_ref must be a non-empty string/.test(threw.message), threw && threw.message); +} + +function downstreamTokenRefOptionalOnStdio() { + // token_ref is never required -- a stdio downstream (or an unauthenticated http one) + // simply omits it, and this must still load cleanly. + const good = { ...VALID, downstream_servers: [{ name: "fs", transport: "stdio", endpoint: "node fixture.js" }] }; + const p = freshFile("downstream-no-token-ref", good); + let threw = null; + try { gatewayConfig.loadConfig(p); } catch (error) { threw = error; } + check("downstream-token-ref-omitted-is-valid", threw === null, threw && threw.message); +} + +function malformedJsonRejected() { + const p = freshFile("malformed", "{not json"); + let threw = null; + try { gatewayConfig.loadConfig(p); } catch (error) { threw = error; } + check("malformed-json-rejected", threw && threw.code === "GATEWAY_CONFIG_MALFORMED", threw && threw.code); +} + +function unreadableFileRejected() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gs-gateway-config-unreadable-")); + let threw = null; + try { gatewayConfig.loadConfig(path.join(dir, "does-not-exist.json")); } catch (error) { threw = error; } + check("unreadable-file-rejected", threw && threw.code === "GATEWAY_CONFIG_UNREADABLE", threw && threw.code); +} + +function agentListenHttpRequiresTokenRef() { + const bad = { ...VALID, agent_listen: { transport: "http" } }; + const p = freshFile("agent-listen-no-token", bad); + let threw = null; + try { gatewayConfig.loadConfig(p); } catch (error) { threw = error; } + check("agent-listen-http-requires-token-ref", threw && /token_ref is required/.test(threw.message), threw && threw.message); +} + +function agentListenStdioValid() { + const good = { ...VALID, agent_listen: { transport: "stdio" } }; + const p = freshFile("agent-listen-stdio", good); + let threw = null; + try { gatewayConfig.loadConfig(p); } catch (error) { threw = error; } + check("agent-listen-stdio-accepted", threw === null, threw && threw.message); +} + +function resolveSecretRefFromEnv() { + process.env.GS_TEST_GATEWAY_SECRET = "super-secret-value"; + try { + const value = gatewayConfig.resolveSecretRef("GS_TEST_GATEWAY_SECRET", "signing_key_ref"); + check("resolve-secret-ref-from-env", value === "super-secret-value", value); + } finally { + delete process.env.GS_TEST_GATEWAY_SECRET; + } +} + +function resolveSecretRefFromFile() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gs-gateway-secret-file-")); + const p = path.join(dir, "key.pem"); + fs.writeFileSync(p, "file-secret-value\n"); + const value = gatewayConfig.resolveSecretRef(p, "signing_key_ref"); + check("resolve-secret-ref-from-file-trims-newline", value === "file-secret-value", JSON.stringify(value)); +} + +function resolveSecretRefUnresolved() { + let threw = null; + try { gatewayConfig.resolveSecretRef("GS_TEST_DOES_NOT_EXIST_ANYWHERE", "signing_key_ref"); } catch (error) { threw = error; } + check("resolve-secret-ref-unresolved-throws", threw && threw.code === "GATEWAY_SECRET_REF_UNRESOLVED", threw && threw.code); +} + +/** CodeRabbit PR #29 review round 4 "trim and reject whitespace-only environment + * variables": a whitespace-only env var value must be treated the same as an unset one -- + * previously it passed the emptiness check untrimmed, then failed much later and less + * clearly (a whitespace-only signing key inside crypto.createPrivateKey(), or a + * whitespace bearer token that can never authenticate). */ +function resolveSecretRefWhitespaceOnlyRejected() { + process.env.GS_TEST_WHITESPACE_SECRET = " \t "; + let threw = null; + try { + gatewayConfig.resolveSecretRef("GS_TEST_WHITESPACE_SECRET", "signing_key_ref"); + } catch (error) { + threw = error; + } finally { + delete process.env.GS_TEST_WHITESPACE_SECRET; + } + check("resolve-secret-ref-whitespace-only-env-rejected", threw && threw.code === "GATEWAY_SECRET_REF_UNRESOLVED", threw && threw.code); +} + +/** Sanity companion to the above: a value that is non-empty AFTER trimming must still + * resolve normally (surrounding whitespace stripped), matching the file-secret branch's + * existing trim behavior. */ +function resolveSecretRefTrimsSurroundingWhitespace() { + process.env.GS_TEST_PADDED_SECRET = " padded-secret-value "; + let value; + try { + value = gatewayConfig.resolveSecretRef("GS_TEST_PADDED_SECRET", "signing_key_ref"); + } finally { + delete process.env.GS_TEST_PADDED_SECRET; + } + check("resolve-secret-ref-trims-surrounding-whitespace", value === "padded-secret-value", JSON.stringify(value)); +} + +function main() { + validConfigLoads(); + missingRequiredField(); + additionalPropertyRejected(); + badDownstreamTransportRejected(); + downstreamTokenRefAccepted(); + downstreamTokenRefMustBeNonEmptyString(); + downstreamTokenRefOptionalOnStdio(); + emptyDownstreamServersRejected(); + duplicateDownstreamNameRejected(); + malformedJsonRejected(); + unreadableFileRejected(); + agentListenHttpRequiresTokenRef(); + agentListenStdioValid(); + resolveSecretRefFromEnv(); + resolveSecretRefFromFile(); + resolveSecretRefUnresolved(); + resolveSecretRefWhitespaceOnlyRejected(); + resolveSecretRefTrimsSurroundingWhitespace(); + + const passed = results.filter((r) => r.status === "PASS").length; + const failed = results.filter((r) => r.status === "FAIL").length; + console.log(`SUMMARY passed=${passed} failed=${failed} skipped=0`); + process.exit(failures ? 1 : 0); +} + +main(); diff --git a/tests/gateway/downstream/run-tests.js b/tests/gateway/downstream/run-tests.js new file mode 100644 index 00000000..cb3afc67 --- /dev/null +++ b/tests/gateway/downstream/run-tests.js @@ -0,0 +1,667 @@ +#!/usr/bin/env node +"use strict"; + +/* Regression suite for scripts/gateway/downstream.js's own transport-level behavior + * (connectStdio/connectHttp/connectAllDownstreams) -- as opposed to + * tests/gateway/proxy/run-tests.js, which fakes this layer out entirely to isolate + * dispatch/correlation logic, and tests/gateway/e2e/run-tests.js, which drives the whole + * gateway process end to end. This suite sits in between: real child processes / a real + * HTTP server, but talking to downstream.js's exported functions directly rather than + * through gateway.js. Added for Codex PR #29 review round 3's downstream.js findings + * (see each test's own header comment for the specific finding it covers). + */ + +const { spawn } = require("child_process"); +const fs = require("fs"); +const http = require("http"); +const os = require("os"); +const path = require("path"); + +const ROOT = path.resolve(__dirname, "../../.."); +const downstream = require(path.join(ROOT, "scripts", "gateway", "downstream.js")); +const FIXTURE_SERVER = path.join(__dirname, "..", "_fixtures", "fixture-mcp-server.js"); + +let failures = 0; +const results = []; +function record(name, status, reason) { + console.log(status === "PASS" ? `PASS ${name}` : status === "SKIP" ? `SKIP ${name}${reason ? " (" + reason + ")" : ""}` : `FAIL ${name}+${reason || "unknown"}`); + results.push({ name, status, reason: reason || "" }); + if (status === "FAIL") failures++; +} +function check(name, cond, reason) { + record(name, cond ? "PASS" : "FAIL", reason); +} +function skip(name, reason) { + record(name, "SKIP", reason); +} + +function freshDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), `gs-gateway-downstream-${prefix}-`)); +} + +/** Codex PR #29 review round 3 "store server information without prototype-sensitive + * keys": a downstream configured with the name "__proto__" must be recorded as a genuine + * own property of serverInfos, not silently reroute into Object.prototype. Uses the real + * fixture (not a fake) so this exercises connectAllDownstreams' actual initialize + * handshake end to end, matching this file's own "real transport" scope. */ +async function prototypePollutingServerNameIsStoredSafely() { + const serverConfig = { name: "__proto__", transport: "stdio", endpoint: `node ${FIXTURE_SERVER} --server-name __proto__` }; + const handles = await downstream.connectAllDownstreams([serverConfig]); + try { + check( + "prototype-polluting-server-name-stored-as-own-property", + Object.prototype.hasOwnProperty.call(handles.serverInfos, "__proto__") && + handles.serverInfos["__proto__"] && + handles.serverInfos["__proto__"].name === "__proto__", + JSON.stringify(handles.serverInfos["__proto__"]) + ); + check( + "prototype-polluting-server-name-does-not-corrupt-object-prototype", + Object.getPrototypeOf({}) === Object.prototype, + String(Object.getPrototypeOf({})) + ); + } finally { + for (const conn of handles.connections.values()) conn.close(); + } +} + +/** Codex PR #29 review round 3 "require the tools array in every tools/list result": a + * downstream whose tools/list result omits "tools" entirely (not merely the wrong type) + * must fail startup rather than silently be treated as an empty tool surface. */ +async function malformedToolsListMissingArrayRejected() { + const dir = freshDir("bad-tools-list"); + const fixturePath = path.join(dir, "bad-tools-list-fixture.js"); + fs.writeFileSync( + fixturePath, + ` + "use strict"; + const readline = require("readline"); + const rl = readline.createInterface({ input: process.stdin, terminal: false }); + function send(msg) { process.stdout.write(JSON.stringify(msg) + "\\n"); } + rl.on("line", (line) => { + let msg; try { msg = JSON.parse(line); } catch (e) { return; } + if (msg.method === "initialize") { send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: { name: "bad", version: "1.0" } } }); return; } + if (msg.method === "tools/list") { send({ jsonrpc: "2.0", id: msg.id, result: {} }); return; } // no "tools" at all + }); + ` + ); + const serverConfig = { name: "bad", transport: "stdio", endpoint: `node ${fixturePath}` }; + let threw = null; + try { + await downstream.connectAllDownstreams([serverConfig]); + } catch (error) { + threw = error; + } + check( + "malformed-tools-list-missing-tools-array-rejected", + Boolean(threw && threw.code === "GATEWAY_DOWNSTREAM_MALFORMED_RESPONSE"), + threw && threw.message + ); +} + +/** Codex PR #29 review "validate the downstream initialize result": a downstream + * returning a well-formed JSON-RPC envelope whose "result" is missing the MCP-required + * protocolVersion/capabilities/serverInfo must fail startup the same way a malformed + * tools/list result already does, instead of continuing with serverInfo: null. */ +async function malformedInitializeResultRejected() { + const dir = freshDir("bad-initialize-result"); + const fixturePath = path.join(dir, "bad-initialize-fixture.js"); + fs.writeFileSync( + fixturePath, + ` + "use strict"; + const readline = require("readline"); + const rl = readline.createInterface({ input: process.stdin, terminal: false }); + function send(msg) { process.stdout.write(JSON.stringify(msg) + "\\n"); } + rl.on("line", (line) => { + let msg; try { msg = JSON.parse(line); } catch (e) { return; } + if (msg.method === "initialize") { send({ jsonrpc: "2.0", id: msg.id, result: {} }); return; } // missing every required field + }); + ` + ); + const serverConfig = { name: "bad", transport: "stdio", endpoint: `node ${fixturePath}` }; + let threw = null; + try { + await downstream.connectAllDownstreams([serverConfig]); + } catch (error) { + threw = error; + } + check( + "malformed-initialize-result-rejected", + Boolean(threw && threw.code === "GATEWAY_DOWNSTREAM_MALFORMED_RESPONSE" && /initialize result/.test(threw.message)), + threw && threw.message + ); +} + +/** Codex PR #29 review round 8 "reject unsupported downstream protocol selections": a + * downstream returning a WELL-FORMED initialize result (passes every check + * malformedInitializeResultRejected above exercises) whose protocolVersion is not in + * SUPPORTED_DOWNSTREAM_PROTOCOL_VERSIONS must still be rejected at startup -- distinctly + * from a malformed/missing field, since this response is shaped correctly, just naming an + * incompatible version this client never negotiated. */ +async function unsupportedProtocolVersionRejected() { + const dir = freshDir("unsupported-protocol-version"); + const fixturePath = path.join(dir, "unsupported-protocol-fixture.js"); + fs.writeFileSync( + fixturePath, + ` + "use strict"; + const readline = require("readline"); + const rl = readline.createInterface({ input: process.stdin, terminal: false }); + function send(msg) { process.stdout.write(JSON.stringify(msg) + "\\n"); } + rl.on("line", (line) => { + let msg; try { msg = JSON.parse(line); } catch (e) { return; } + if (msg.method === "initialize") { + // Well-formed per every existing shape check, but a version this gateway never + // negotiated (it only ever sends "2025-06-18" as its own declared protocolVersion). + send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: "1999-01-01", capabilities: {}, serverInfo: { name: "old-server", version: "1.0" } } }); + return; + } + }); + ` + ); + const serverConfig = { name: "old", transport: "stdio", endpoint: `node ${fixturePath}` }; + let threw = null; + try { + await downstream.connectAllDownstreams([serverConfig]); + } catch (error) { + threw = error; + } + check( + "unsupported-protocol-version-rejected-at-startup", + Boolean(threw && threw.code === "GATEWAY_DOWNSTREAM_UNSUPPORTED_PROTOCOL_VERSION" && /1999-01-01/.test(threw.message)), + threw && threw.message + ); +} + +/** Codex PR #29 review round 8 "bound termination of stdio downstream children": a + * downstream that traps SIGTERM and keeps running must still be terminated -- close() now + * escalates to SIGKILL (which cannot be trapped) after its terminate-timeout, and returns + * a promise that resolves only once the child has actually exited. Passes a short + * `terminateTimeoutMs` via connectStdio's own options (mirrors this file's existing + * per-connection options-override convention) instead of waiting out the real production + * default, so this test runs fast rather than for STDIO_CHILD_TERMINATE_TIMEOUT_MS. + * + * FR-12 (2026-09-14): the timing half of this test (asserting close() takes at least + * terminateTimeoutMs, proving the SIGKILL-escalation branch actually ran rather than the + * child exiting on its own) is skipped on win32. Windows has no true POSIX signal + * delivery -- child.kill() (Node's SIGTERM stand-in) unconditionally force-terminates the + * child regardless of any process.on("SIGTERM", ...) handler it registered, so this + * fixture's trap never actually traps anything and the child exits in a few ms instead of + * surviving to the timeout floor. Confirmed via real GitHub Actions CI: reproducible on + * both windows-latest/node18 and windows-latest/node22, both workflow-run triggers + * (took 3-4ms against a >=150ms floor), root-caused by reading downstream.js's close() + * directly (a correct, platform-agnostic bounded-wait-then-SIGKILL design -- no production + * defect). Mirrors this repo's existing precedent (tests/gateway/e2e's + * e2e-sigterm-clean-drain-and-release) of skipping a platform-unreproducible signal + * assertion rather than writing one that cannot mean what it claims on win32. The other + * half of this same test (close-resolves-once-sigterm-trapping-child-is-actually-gone, + * which only checks the connection ends up closed and does not depend on trap timing) + * still runs and is expected to pass on every platform, including win32. */ +async function stdioChildTerminationEscalatesToSigkillAfterTimeout() { + const dir = freshDir("sigterm-trap"); + const fixturePath = path.join(dir, "sigterm-trap-fixture.js"); + fs.writeFileSync( + fixturePath, + ` + "use strict"; + process.on("SIGTERM", () => {}); // trap and ignore -- never exits on SIGTERM alone + setInterval(() => {}, 1000); // keep the event loop alive independent of stdin closing + const readline = require("readline"); + const rl = readline.createInterface({ input: process.stdin, terminal: false }); + function send(msg) { process.stdout.write(JSON.stringify(msg) + "\\n"); } + rl.on("line", (line) => { + let msg; try { msg = JSON.parse(line); } catch (e) { return; } + if (msg.method === "initialize") { send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: { name: "trap", version: "1.0" } } }); } + }); + ` + ); + const conn = downstream.connectStdio(`node ${fixturePath}`, { terminateTimeoutMs: 150 }); + await conn.call("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "test", version: "1.0" } }); + + const start = Date.now(); + await conn.close(); + const elapsed = Date.now() - start; + check("close-resolves-once-sigterm-trapping-child-is-actually-gone", conn.isClosed() === true, String(conn.isClosed())); + if (process.platform === "win32") { + skip( + "close-escalated-to-sigkill-rather-than-hanging-on-a-trapped-sigterm", + "Windows cannot deliver a real SIGTERM for graceful in-process handling (child.kill('SIGTERM') force-terminates on win32), so this fixture's SIGTERM trap never actually traps anything and the child exits in a few ms instead of surviving to the escalation-timeout floor -- mirrors this repo's existing precedent (tests/gateway/e2e's e2e-sigterm-clean-drain-and-release) of skipping a platform-unreproducible signal assertion rather than writing one that cannot mean what it claims." + ); + } else { + check( + "close-escalated-to-sigkill-rather-than-hanging-on-a-trapped-sigterm", + elapsed >= 150 && elapsed < 5000, + `took ${elapsed}ms` + ); + } +} + +/** Codex PR #29 review round 8 "bound termination of stdio downstream children": a + * well-behaved child that exits promptly on SIGTERM must not be made to wait out the + * escalation timeout -- close() should resolve as soon as the child's own "close" event + * fires, not only once STDIO_CHILD_TERMINATE_TIMEOUT_MS elapses. */ +async function stdioChildTerminationResolvesPromptlyOnCleanExit() { + const dir = freshDir("clean-sigterm"); + const fixturePath = path.join(dir, "clean-exit-fixture.js"); + fs.writeFileSync( + fixturePath, + ` + "use strict"; + const readline = require("readline"); + const rl = readline.createInterface({ input: process.stdin, terminal: false }); + function send(msg) { process.stdout.write(JSON.stringify(msg) + "\\n"); } + rl.on("line", (line) => { + let msg; try { msg = JSON.parse(line); } catch (e) { return; } + if (msg.method === "initialize") { send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: { name: "clean", version: "1.0" } } }); } + }); + ` + ); + const conn = downstream.connectStdio(`node ${fixturePath}`); + await conn.call("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "test", version: "1.0" } }); + const start = Date.now(); + await conn.close(); + const elapsed = Date.now() - start; + check("close-on-a-well-behaved-child-resolves-well-under-the-escalation-timeout", elapsed < downstream.STDIO_CHILD_TERMINATE_TIMEOUT_MS, `took ${elapsed}ms`); +} + +/** Codex PR #29 review "keep gateway secrets out of downstream subprocess environments": + * buildStdioChildEnv strips exactly the named secret env vars and nothing else, and + * connectStdio actually spawns its child with that filtered environment rather than the + * gateway process's own full env. */ +async function stdioChildEnvExcludesGatewaySecrets() { + check( + "build-stdio-child-env-strips-only-named-keys", + (() => { + const saved = { A: process.env.GS_TEST_SECRET_A, B: process.env.GS_TEST_KEEP_B }; + process.env.GS_TEST_SECRET_A = "top-secret"; + process.env.GS_TEST_KEEP_B = "keep-me"; + const filtered = downstream.buildStdioChildEnv(new Set(["GS_TEST_SECRET_A"])); + const result = filtered.GS_TEST_SECRET_A === undefined && filtered.GS_TEST_KEEP_B === "keep-me"; + if (saved.A === undefined) delete process.env.GS_TEST_SECRET_A; else process.env.GS_TEST_SECRET_A = saved.A; + if (saved.B === undefined) delete process.env.GS_TEST_KEEP_B; else process.env.GS_TEST_KEEP_B = saved.B; + return result; + })() + ); + + const dir = freshDir("stdio-secret-env"); + const fixturePath = path.join(dir, "env-echo-fixture.js"); + fs.writeFileSync( + fixturePath, + ` + "use strict"; + const readline = require("readline"); + const rl = readline.createInterface({ input: process.stdin, terminal: false }); + function send(msg) { process.stdout.write(JSON.stringify(msg) + "\\n"); } + rl.on("line", (line) => { + let msg; try { msg = JSON.parse(line); } catch (e) { return; } + if (msg.method === "initialize") { send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: { name: "env-echo", version: "1.0" } } }); return; } + if (msg.method === "tools/list") { send({ jsonrpc: "2.0", id: msg.id, result: { tools: [] } }); return; } + if (msg.method === "gs/dumpEnv") { send({ jsonrpc: "2.0", id: msg.id, result: { GS_TEST_GATEWAY_SECRET: process.env.GS_TEST_GATEWAY_SECRET === undefined ? null : process.env.GS_TEST_GATEWAY_SECRET } }); return; } + }); + ` + ); + process.env.GS_TEST_GATEWAY_SECRET = "signing-key-material"; + const conn = downstream.connectStdio(`node ${fixturePath}`, { secretEnvNames: new Set(["GS_TEST_GATEWAY_SECRET"]) }); + try { + await conn.call("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "test", version: "1.0" } }); + const dumped = await conn.call("gs/dumpEnv", {}); + check("stdio-child-does-not-inherit-gateway-secret-env-var", dumped.GS_TEST_GATEWAY_SECRET === null, JSON.stringify(dumped)); + } finally { + conn.close(); + delete process.env.GS_TEST_GATEWAY_SECRET; + } +} + +/** Codex PR #29 review "surface unmatched stdio responses to the session recorder": a + * stdio response whose id has no live pending call must reach options.onUnmatchedResponse + * instead of vanishing silently at the transport layer. */ +async function unmatchedStdioResponseSurfaced() { + const dir = freshDir("unmatched-response"); + const fixturePath = path.join(dir, "unmatched-response-fixture.js"); + fs.writeFileSync( + fixturePath, + ` + "use strict"; + const readline = require("readline"); + const rl = readline.createInterface({ input: process.stdin, terminal: false }); + function send(msg) { process.stdout.write(JSON.stringify(msg) + "\\n"); } + rl.on("line", (line) => { + let msg; try { msg = JSON.parse(line); } catch (e) { return; } + if (msg.method === "initialize") { + send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: { name: "unmatched", version: "1.0" } } }); + // Immediately follow with a bogus response for an id this client never sent. + send({ jsonrpc: "2.0", id: 999999, result: { surprise: true } }); + return; + } + }); + ` + ); + const unmatched = []; + /* CodeRabbit PR #29 review round 8 "wait for the callback instead of waiting 200 ms": a + * fixed delay is flaky under a slow CI worker, which can deliver the bogus line after + * this test already moved on to its assertion. Resolve a promise from the callback + * itself and use a bounded timeout only as the FAILURE limit, not as the expected + * delivery time. */ + let resolveUnmatched; + const unmatchedReceived = new Promise((resolve) => { resolveUnmatched = resolve; }); + const conn = downstream.connectStdio(`node ${fixturePath}`, { + onUnmatchedResponse: (msg) => { + unmatched.push(msg); + resolveUnmatched(); + }, + }); + try { + await conn.call("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "test", version: "1.0" } }); + await Promise.race([ + unmatchedReceived, + new Promise((_, reject) => setTimeout(() => reject(new Error("unmatched response not received")), 2000)), + ]); + check( + "unmatched-stdio-response-surfaced-to-callback", + unmatched.length === 1 && unmatched[0].id === 999999, + JSON.stringify(unmatched) + ); + } finally { + conn.close(); + } +} + +/** Codex PR #29 review round 3 "require a result or error in HTTP responses": a + * downstream HTTP response carrying the right jsonrpc/id but neither "result" nor + * "error" (or, symmetrically, both) must fail closed instead of resolving `undefined` as + * a false success. */ +async function httpResponseRequiresExactlyOneOfResultOrError() { + const responses = []; + const server = http.createServer((req, res) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + const next = responses.shift(); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(typeof next === "function" ? next(body.id) : next)); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + const conn = downstream.connectHttp(`http://127.0.0.1:${port}/`); + try { + responses.push((id) => ({ jsonrpc: "2.0", id })); // neither result nor error + let threwNeither = null; + try { await conn.call("tools/list", {}, 2000); } catch (error) { threwNeither = error; } + check("http-response-with-neither-result-nor-error-rejected", Boolean(threwNeither && threwNeither.code === "GATEWAY_DOWNSTREAM_MALFORMED_RESPONSE"), threwNeither && threwNeither.message); + + responses.push((id) => ({ jsonrpc: "2.0", id, result: { ok: true }, error: { code: -32000, message: "also an error" } })); // both + let threwBoth = null; + try { await conn.call("tools/list", {}, 2000); } catch (error) { threwBoth = error; } + check("http-response-with-both-result-and-error-rejected", Boolean(threwBoth && threwBoth.code === "GATEWAY_DOWNSTREAM_MALFORMED_RESPONSE"), threwBoth && threwBoth.message); + + responses.push((id) => ({ jsonrpc: "2.0", id, result: { ok: true } })); // sanity: well-formed still resolves + const ok = await conn.call("tools/list", {}, 2000); + check("http-response-with-only-result-still-resolves", ok && ok.ok === true, JSON.stringify(ok)); + } finally { + conn.close(); + await new Promise((resolve) => server.close(resolve)); + } +} + +/** Codex PR #29 review round 3 "bound newline-delimited responses from stdio + * downstreams": a downstream that writes a large, unterminated line must not be allowed + * to buffer unboundedly in the gateway -- the connection should force-close once a single + * unterminated line exceeds the shared MAX_HTTP_RESPONSE_BYTES-sized cap, rather than let + * memory grow without limit. */ +async function unboundedUnterminatedLineForcesClose() { + const dir = freshDir("unbounded-line"); + const fixturePath = path.join(dir, "unbounded-line-fixture.js"); + fs.writeFileSync( + fixturePath, + ` + "use strict"; + const readline = require("readline"); + const rl = readline.createInterface({ input: process.stdin, terminal: false }); + function send(msg) { process.stdout.write(JSON.stringify(msg) + "\\n"); } + rl.on("line", (line) => { + let msg; try { msg = JSON.parse(line); } catch (e) { return; } + if (msg.method === "initialize") { + send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: { name: "bad", version: "1.0" } } }); + // Immediately after replying, start writing an unterminated line that never + // stops -- large chunks, no trailing newline. + const chunk = "x".repeat(1024 * 1024); + const timer = setInterval(() => process.stdout.write(chunk), 5); + setTimeout(() => { clearInterval(timer); process.exit(0); }, 5000).unref(); + } + }); + ` + ); + const conn = downstream.connectStdio(`node ${fixturePath}`); + try { + await conn.call("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "t", version: "1" } }); + const closedWithin = await Promise.race([ + conn.whenClosed().then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 4000)), + ]); + check("unbounded-unterminated-line-force-closes-connection", closedWithin === true, "connection did not self-close within 4s"); + } finally { + conn.close(); + } +} + +/** CodeRabbit PR #29 review round 4 "track only the bytes after the last newline": a + * single chunk containing several COMPLETE, well-formed, newline-terminated lines whose + * combined length exceeds MAX_HTTP_RESPONSE_BYTES must NOT force-close the connection -- + * only a genuinely unterminated line that itself exceeds the cap should. Before the fix, + * readline's own "data" listener (registered first) consumed and reset the byte counter + * for each line, and then this listener still added the WHOLE chunk's length on top, + * misreporting bounded, healthy traffic as a single oversized unterminated line. */ +async function completeLinesBatchedInOneChunkDoNotForceClose() { + const dir = freshDir("batched-lines"); + const fixturePath = path.join(dir, "batched-lines-fixture.js"); + fs.writeFileSync( + fixturePath, + ` + "use strict"; + const readline = require("readline"); + const rl = readline.createInterface({ input: process.stdin, terminal: false }); + function send(msg) { process.stdout.write(JSON.stringify(msg) + "\\n"); } + rl.on("line", (line) => { + let msg; try { msg = JSON.parse(line); } catch (e) { return; } + if (msg.method === "initialize") { + send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: { name: "batch", version: "1.0" } } }); + return; + } + if (msg.method === "tools/list") { + // Write several complete, newline-terminated, oversized-when-combined lines in + // ONE process.stdout.write() call -- i.e. one "data" event downstream.js's side. + const big = "x".repeat(4 * 1024 * 1024); // 4MiB per padded line, 3 lines = 12MiB > 10MiB cap + const lines = [0, 1, 2].map((i) => JSON.stringify({ jsonrpc: "2.0", id: "extra-" + i, unexpected: big })); + process.stdout.write(lines.join("\\n") + "\\n"); + send({ jsonrpc: "2.0", id: msg.id, result: { tools: [] } }); + return; + } + }); + ` + ); + const conn = downstream.connectStdio(`node ${fixturePath}`); + try { + await conn.call("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "t", version: "1" } }); + const toolsResult = await conn.call("tools/list", {}, 4000); + check("complete-lines-batched-in-one-chunk-do-not-force-close", Array.isArray(toolsResult && toolsResult.tools), JSON.stringify(toolsResult)); + } finally { + conn.close(); + } +} + +/** CodeRabbit PR #29 review round 4 "make this regression test deterministic": + * completeLinesBatchedInOneChunkDoNotForceClose above drives the fix indirectly through + * a real child process's stdout, relying on a single process.stdout.write() call + * arriving as one "data" chunk on this side -- but the OS pipe is free to (and on Linux + * typically does, with its ~64KB default pipe buffer) split that into many smaller + * chunks regardless, so that test could pass against the OLD buggy logic too without + * ever actually exercising the multiple-complete-lines-in-one-chunk case it claims to + * cover. This test instead drives downstream.js's own exported, pure + * nextResidualLineBytes helper directly with explicit in-memory Buffers -- no child + * process or OS pipe chunking involved, so it deterministically reproduces (and would + * fail against) the exact old bug: several complete, individually-under-the-cap lines + * combined into one chunk whose SUM exceeds MAX_HTTP_RESPONSE_BYTES. */ +function residualLineBytesHelperIsDeterministic() { + const MAX = downstream.MAX_HTTP_RESPONSE_BYTES; + + // Three complete, newline-terminated lines combined into ONE chunk, each individually + // well under the cap but whose sum exceeds it. + const perLine = Math.floor(MAX / 3) + 1000; + const line = "x".repeat(perLine); + const oneChunk = Buffer.from([line, line, line].join("\n") + "\n", "utf8"); + check("residual-line-bytes-setup-batched-chunk-exceeds-cap-if-misreported", oneChunk.length > MAX, String(oneChunk.length)); + const afterBatchedChunk = downstream.nextResidualLineBytes(0, oneChunk); + check( + "residual-line-bytes-batched-complete-lines-in-one-chunk-resets-to-zero-not-the-whole-chunk", + afterBatchedChunk === 0, + String(afterBatchedChunk) + ); + + // A genuinely unterminated chunk (no newline at all) extends the residual by its full + // length, and that residual carries forward correctly across successive chunks. + const afterFirstPartial = downstream.nextResidualLineBytes(0, Buffer.from("x".repeat(100), "utf8")); + check("residual-line-bytes-no-newline-chunk-extends-residual", afterFirstPartial === 100, String(afterFirstPartial)); + const afterSecondPartial = downstream.nextResidualLineBytes(afterFirstPartial, Buffer.from("y".repeat(50), "utf8")); + check("residual-line-bytes-residual-carries-forward-across-chunks", afterSecondPartial === 150, String(afterSecondPartial)); + + // A chunk containing a newline followed by trailing unterminated bytes resets to just + // those trailing bytes, discarding whatever residual had built up before the newline. + const afterNewlineThenTrailing = downstream.nextResidualLineBytes(999999, Buffer.from("z".repeat(10) + "\n" + "w".repeat(7), "utf8")); + check( + "residual-line-bytes-newline-then-trailing-bytes-resets-to-trailing-length", + afterNewlineThenTrailing === 7, + String(afterNewlineThenTrailing) + ); +} + +/** Codex PR #29 review round 4 "advertise sampling before accepting sampling requests": + * connectAllDownstreams must declare the MCP `sampling` client capability during + * initialize when the caller says the agent transport can relay it, and must NOT declare + * it otherwise -- a conforming downstream only sends sampling/createMessage to a client + * that has actually negotiated support for it. */ +async function samplingCapabilityAdvertisedOnlyWhenSupported() { + const dir = freshDir("sampling-capability"); + const fixturePath = path.join(dir, "capture-init-fixture.js"); + const capturePath = path.join(dir, "captured-init.json"); + fs.writeFileSync( + fixturePath, + ` + "use strict"; + const fs = require("fs"); + const readline = require("readline"); + const rl = readline.createInterface({ input: process.stdin, terminal: false }); + function send(msg) { process.stdout.write(JSON.stringify(msg) + "\\n"); } + rl.on("line", (line) => { + let msg; try { msg = JSON.parse(line); } catch (e) { return; } + if (msg.method === "initialize") { + fs.writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify(msg.params)); + send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: { name: "cap", version: "1.0" } } }); + return; + } + if (msg.method === "tools/list") { send({ jsonrpc: "2.0", id: msg.id, result: { tools: [] } }); return; } + }); + ` + ); + const serverConfig = { name: "cap", transport: "stdio", endpoint: `node ${fixturePath}` }; + + const withSampling = await downstream.connectAllDownstreams([serverConfig], { supportsSampling: true }); + const capturedWith = JSON.parse(fs.readFileSync(capturePath, "utf8")); + for (const conn of withSampling.connections.values()) conn.close(); + check( + "sampling-capability-advertised-when-supported", + Boolean(capturedWith.capabilities && capturedWith.capabilities.sampling && typeof capturedWith.capabilities.sampling === "object"), + JSON.stringify(capturedWith.capabilities) + ); + + const withoutSampling = await downstream.connectAllDownstreams([serverConfig], { supportsSampling: false }); + const capturedWithout = JSON.parse(fs.readFileSync(capturePath, "utf8")); + for (const conn of withoutSampling.connections.values()) conn.close(); + check( + "sampling-capability-omitted-when-not-supported", + Boolean(capturedWithout.capabilities) && !("sampling" in capturedWithout.capabilities), + JSON.stringify(capturedWithout.capabilities) + ); +} + +/** Codex PR #29 review round 4 "preserve the originating server for sampling": with + * multiple stdio downstreams sharing one `onRequest` callback, connectAllDownstreams must + * bind each connection's OWN configured server name into its own callback invocation -- + * not have every downstream's unsolicited request reach onRequest indistinguishably. */ +async function onRequestReceivesOwnServerName() { + const dir = freshDir("onrequest-server-name"); + function writeSamplerFixture(name) { + const fixturePath = path.join(dir, `${name}-fixture.js`); + fs.writeFileSync( + fixturePath, + ` + "use strict"; + const readline = require("readline"); + const rl = readline.createInterface({ input: process.stdin, terminal: false }); + function send(msg) { process.stdout.write(JSON.stringify(msg) + "\\n"); } + rl.on("line", (line) => { + let msg; try { msg = JSON.parse(line); } catch (e) { return; } + if (typeof msg.method !== "string" && msg.id === "upstream-1") { return; } // reply to our own request, ignored here + if (msg.method === "initialize") { send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: { name: ${JSON.stringify(name)}, version: "1.0" } } }); return; } + if (msg.method === "tools/list") { + send({ jsonrpc: "2.0", id: msg.id, result: { tools: [] } }); + // Immediately after the handshake, fire our own unsolicited request upstream. + send({ jsonrpc: "2.0", id: "upstream-1", method: "sampling/createMessage", params: {} }); + return; + } + }); + ` + ); + return fixturePath; + } + const fixtureA = writeSamplerFixture("server-a"); + const fixtureB = writeSamplerFixture("server-b"); + const seenServerNames = []; + const handles = await downstream.connectAllDownstreams( + [ + { name: "server-a", transport: "stdio", endpoint: `node ${fixtureA}` }, + { name: "server-b", transport: "stdio", endpoint: `node ${fixtureB}` }, + ], + { + supportsSampling: true, + onRequest: (msg, serverName) => { seenServerNames.push(serverName); return Promise.resolve(null); }, + } + ); + try { + await new Promise((resolve) => setTimeout(resolve, 500)); // let both fixtures' unsolicited requests arrive + check( + "onrequest-receives-each-connections-own-server-name", + seenServerNames.includes("server-a") && seenServerNames.includes("server-b") && seenServerNames.length === 2, + JSON.stringify(seenServerNames) + ); + } finally { + for (const conn of handles.connections.values()) conn.close(); + } +} + +async function main() { + await prototypePollutingServerNameIsStoredSafely(); + await malformedToolsListMissingArrayRejected(); + await malformedInitializeResultRejected(); + await unsupportedProtocolVersionRejected(); + await stdioChildTerminationEscalatesToSigkillAfterTimeout(); + await stdioChildTerminationResolvesPromptlyOnCleanExit(); + await stdioChildEnvExcludesGatewaySecrets(); + await unmatchedStdioResponseSurfaced(); + await httpResponseRequiresExactlyOneOfResultOrError(); + await unboundedUnterminatedLineForcesClose(); + await completeLinesBatchedInOneChunkDoNotForceClose(); + residualLineBytesHelperIsDeterministic(); + await samplingCapabilityAdvertisedOnlyWhenSupported(); + await onRequestReceivesOwnServerName(); + + const passed = results.filter((r) => r.status === "PASS").length; + const failed = results.filter((r) => r.status === "FAIL").length; + const skipped = results.filter((r) => r.status === "SKIP").length; + console.log(`SUMMARY passed=${passed} failed=${failed} skipped=${skipped}`); + process.exit(failures ? 1 : 0); +} + +main(); diff --git a/tests/gateway/e2e/run-tests.js b/tests/gateway/e2e/run-tests.js new file mode 100644 index 00000000..d1c007af --- /dev/null +++ b/tests/gateway/e2e/run-tests.js @@ -0,0 +1,834 @@ +#!/usr/bin/env node +"use strict"; + +/* End-to-end integration suite: spawns the REAL gateway CLI (scripts/gateway/gateway.js) + * as a child process, talking to a REAL, separate downstream fixture process + * (tests/gateway/_fixtures/fixture-mcp-server.js) over real stdio pipes -- not + * unit-level fakes. Proves the whole pipeline SG-FR-1 through SG-FR-5/SG-FR-7 actually + * works end to end, matching the Standalone Gateway TRD's SS8 test plan: + * 1 single agent, single downstream, one tool call, clean disconnect -> exactly one + * bundle written, verifies under gsa-verify.js, matches the session's actual calls. + * 5 clean shutdown (SIGTERM) with a session in flight -> drains and finalizes before + * exit, claim released, process exits 0. SKIPPED ON WIN32: Node cannot deliver a + * real SIGTERM for graceful in-process handling on Windows (child.kill('SIGTERM') + * unconditionally terminates the process there) -- mirrors this repo's own existing + * precedent (tests/state-store/writer-claim, "skip win32-unreproducible renew() + * TOCTOU simulation") of naming and skipping a platform-unreproducible case rather + * than writing a test that cannot mean what it claims to mean. + * 6 second gateway instance started against the same state_dir while the first is + * running -> FR-1 refusal, named identity in the error. + * 15 mode configured as attach but the standalone binary is started anyway -> logs + * dormant, exits 0, binds no port, holds no claim. + */ + +const { spawn } = require("child_process"); +const crypto = require("crypto"); +const fs = require("fs"); +const http = require("http"); +const os = require("os"); +const path = require("path"); +const readline = require("readline"); + +const ROOT = path.resolve(__dirname, "../../.."); +const GATEWAY_CLI = path.join(ROOT, "scripts", "gateway", "gateway.js"); +const FIXTURE_SERVER = path.join(__dirname, "..", "_fixtures", "fixture-mcp-server.js"); +const { writeConfirmedMode } = require("../_fixtures/mode-file.js"); +const { walkGatewaySessions } = require(path.join(ROOT, "checks", "register-gateway-sessions.js")); +const chain = require(path.join(ROOT, "scripts", "gateway", "chain.js")); +const { verifyBundle } = require(path.join(ROOT, "scripts", "gsa-verify.js")); + +let failures = 0; +const results = []; +function record(name, status, reason) { + console.log(status === "PASS" ? `PASS ${name}` : status === "SKIP" ? `SKIP ${name}${reason ? " (" + reason + ")" : ""}` : `FAIL ${name}+${reason || "unknown"}`); + results.push({ name, status, reason: reason || "" }); + if (status === "FAIL") failures++; +} +function check(name, cond, reason) { + record(name, cond ? "PASS" : "FAIL", reason); +} +function skip(name, reason) { + record(name, "SKIP", reason); +} + +function freshRoot(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), `gs-gateway-e2e-${prefix}-`)); +} + +function writeSigningKey(root) { + const kp = crypto.generateKeyPairSync("ed25519"); + const pem = kp.privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const keyPath = path.join(root, "signing-key.pem"); + fs.writeFileSync(keyPath, pem); + const publicPem = kp.publicKey.export({ type: "spki", format: "pem" }).toString(); + return { keyPath, publicPem, signer: "graphsmith-standalone-gateway" }; +} + +function writeGatewayConfig(root, options = {}) { + const stateDir = path.join(root, "state"); + const { keyPath } = writeSigningKey(root); + const config = { + schema_version: "1.0", + state_dir: stateDir, + downstream_servers: [{ name: "fixture", transport: "stdio", endpoint: `node ${FIXTURE_SERVER} --server-name fixture` }], + signing_key_ref: keyPath, + ...options, + }; + const configPath = path.join(root, "gateway-config.json"); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + return { configPath, stateDir }; +} + +/** Spawns the real gateway CLI against `configPath`, cwd=`root` (so it reads + * /.graphsmith/gateway-mode.json per SS3.8). Returns a small driver object. */ +function spawnGateway(root, configPath) { + const child = spawn(process.execPath, [GATEWAY_CLI, configPath], { cwd: root, stdio: ["pipe", "pipe", "pipe"] }); + const rl = readline.createInterface({ input: child.stdout, terminal: false }); + const lineQueue = []; + const waiters = []; + rl.on("line", (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + let msg; + try { msg = JSON.parse(trimmed); } catch (error) { return; } + if (waiters.length > 0) waiters.shift()(msg); + else lineQueue.push(msg); + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); }); + + function nextMessage(timeoutMs = 10000) { + if (lineQueue.length > 0) return Promise.resolve(lineQueue.shift()); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`timed out waiting for a gateway response (stderr so far: ${stderr})`)), timeoutMs); + waiters.push((msg) => { clearTimeout(timer); resolve(msg); }); + }); + } + function send(msg) { + child.stdin.write(JSON.stringify(msg) + "\n"); + } + function exitCode() { + return new Promise((resolve) => child.on("close", (code) => resolve(code))); + } + return { child, send, nextMessage, exitCode, stderr: () => stderr }; +} + +async function singleSessionEndToEndVerifies() { + const root = freshRoot("happy"); + writeConfirmedMode(root, "standalone"); + const { configPath, stateDir } = writeGatewayConfig(root); + const gw = spawnGateway(root, configPath); + + gw.send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { clientInfo: { name: "test-agent", version: "1.0" } } }); + const initResp = await gw.nextMessage(); + check("e2e-initialize-responds", initResp && initResp.result && initResp.result.serverInfo, JSON.stringify(initResp)); + gw.send({ jsonrpc: "2.0", method: "notifications/initialized" }); + + gw.send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); + const toolsResp = await gw.nextMessage(); + const toolNames = toolsResp && toolsResp.result && toolsResp.result.tools.map((t) => t.name); + check("e2e-tools-list-includes-fixture-tools", Array.isArray(toolNames) && toolNames.includes("fixture_echo"), JSON.stringify(toolsResp)); + + gw.send({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "fixture_echo", arguments: { hello: "world" } } }); + const callResp = await gw.nextMessage(); + check("e2e-tool-call-round-trips-through-real-downstream", callResp && callResp.result && /hello/.test(JSON.stringify(callResp.result)), JSON.stringify(callResp)); + + gw.child.stdin.end(); // clean agent disconnect -> finalize + persist + graceful exit + const code = await gw.exitCode(); + check("e2e-clean-disconnect-exits-zero", code === 0, `exit code ${code}; stderr: ${gw.stderr()}`); + + const head = chain.readHead(stateDir); + check("e2e-exactly-one-bundle-written", head && head.seq === 1, JSON.stringify(head)); + const bundleFile = chain.bundlePath(stateDir, head.bundle_id); + check("e2e-bundle-file-exists", fs.existsSync(bundleFile), bundleFile); + const bundle = JSON.parse(fs.readFileSync(bundleFile, "utf8")); + + const kp = crypto.createPublicKey(fs.readFileSync(path.join(root, "signing-key.pem"), "utf8")); + const publicPem = kp.export({ type: "spki", format: "pem" }).toString(); + const verified = verifyBundle(bundle, { trustedKeys: { "graphsmith-standalone-gateway": publicPem } }); + check("e2e-bundle-verifies-under-gsa-verify", verified.status === "PASS", JSON.stringify(verified)); + + const traceStr = bundle.contents["execution_trace.jsonl"]; + check("e2e-bundle-matches-the-actual-tool-call-made", /"tool":"fixture:fixture_echo"/.test(traceStr), traceStr); + + const chainResult = walkGatewaySessions({ + chain: chain.readChain(stateDir), + head: chain.readHead(stateDir), + computeEntrySha256: chain.computeEntrySha256, + bundleExists: (id) => fs.existsSync(chain.bundlePath(stateDir, id)), + }); + check("e2e-chain-verifies", chainResult.status === "verified", JSON.stringify(chainResult)); +} + +/** Waits for the HTTP agent listener's port announcement on stderr (rather than a + * fixed sleep -- matches this suite's own established "signal, not sleep" discipline, + * see cleanSigtermDrainsAndExitsZero's comment). */ +function waitForHttpPort(gw, timeoutMs = 10000) { + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeoutMs; + const poll = () => { + const match = /agent-facing HTTP listener on port (\d+)/.exec(gw.stderr()); + if (match) { + resolve(Number(match[1])); + return; + } + if (Date.now() > deadline) { + reject(new Error(`timed out waiting for the HTTP listener's port announcement (stderr so far: ${gw.stderr()})`)); + return; + } + setTimeout(poll, 25); + }; + poll(); + }); +} + +/** Posts one JSON-RPC message to the agent-facing HTTP listener. `sessionId`, when + * given, is echoed on the `Mcp-Session-Id` request header (board decision 2026-09-08: + * sessions are identified by this explicit header, not by TCP socket identity -- see + * scripts/gateway/agent-transport.js#runHttpAgentTransport's own header comment). + * Resolves `{ body, headers }` (not just the parsed body) so callers can read the + * session ID the server minted/echoed back. */ +function httpPost(port, token, body, agent, sessionId) { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(body); + const headers = { "content-type": "application/json", "content-length": Buffer.byteLength(payload), authorization: `Bearer ${token}` }; + if (sessionId) headers["mcp-session-id"] = sessionId; + const req = http.request( + { host: "127.0.0.1", port, method: "POST", agent, headers }, + (res) => { + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => { + try { + resolve({ body: JSON.parse(Buffer.concat(chunks).toString("utf8")), headers: res.headers }); + } catch (error) { + reject(new Error(`HTTP response was not valid JSON: ${error.message}`)); + } + }); + } + ); + req.on("error", reject); + req.end(payload); + }); +} + +/** Codex PR #29 review round 8 "wait for notifications/initialized before admitting + * tools": a plain JSON-RPC notification (no "id") over the agent HTTP transport gets an + * empty 202 body back (agent-transport.js's own handling of a null handleMessage() + * response), unlike httpPost's request/response pairs above, which always expect a JSON + * body -- this variant tolerates that empty body instead of treating it as a parse error. */ +function httpNotify(port, token, body, agent, sessionId) { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(body); + const headers = { "content-type": "application/json", "content-length": Buffer.byteLength(payload), authorization: `Bearer ${token}` }; + if (sessionId) headers["mcp-session-id"] = sessionId; + const req = http.request({ host: "127.0.0.1", port, method: "POST", agent, headers }, (res) => { + res.resume(); + res.on("end", resolve); + }); + req.on("error", reject); + req.end(payload); + }); +} + +/** Board decision 2026-09-04 (PR #29 review, Decision 1, Option B): a downstream + * server's own unsolicited "sampling/createMessage" request is forwarded to the agent + * ONLY when the agent transport is stdio (the only one that can push a request rather + * than merely reply to one). Proves the fixture's own upstream request round-trips + * through the real gateway process to a real stdio agent and back. */ +async function samplingForwardedToStdioAgent() { + const root = freshRoot("sampling-stdio"); + writeConfirmedMode(root, "standalone"); + const { configPath, stateDir } = writeGatewayConfig(root); + const gw = spawnGateway(root, configPath); + + gw.send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { clientInfo: { name: "test-agent", version: "1.0" } } }); + await gw.nextMessage(); + gw.send({ jsonrpc: "2.0", method: "notifications/initialized" }); + + gw.send({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "fixture_sample", arguments: { prompt: "hello from downstream" } } }); + + const pushed = await gw.nextMessage(); + check( + "e2e-sampling-forwarded-to-stdio-agent", + pushed && pushed.method === "sampling/createMessage" && pushed.id !== undefined && pushed.id !== null && !("result" in pushed) && !("error" in pushed), + JSON.stringify(pushed) + ); + + gw.send({ jsonrpc: "2.0", id: pushed && pushed.id, result: { role: "assistant", content: { type: "text", text: "mocked model output" } } }); + + const toolResp = await gw.nextMessage(); + check( + "e2e-sampling-tool-call-resolves-with-forwarded-agent-result", + toolResp && toolResp.id === 2 && toolResp.result && /mocked model output/.test(JSON.stringify(toolResp.result)), + JSON.stringify(toolResp) + ); + + gw.child.stdin.end(); + const code = await gw.exitCode(); + check("e2e-sampling-stdio-clean-disconnect-exits-zero", code === 0, `exit code ${code}; stderr: ${gw.stderr()}`); + + /* Codex PR #29 review "record downstream-initiated sampling in the session": before + * that fix, this forwarded exchange never touched session.js at all, so the sealed + * bundle attested only the outer fixture_sample tool call -- the model invocation + * itself (and its hashed prompt/result) was silently absent even though the gateway + * observed and relayed it. Assert it now actually lands in the persisted trace with + * model_call:true, not just that the tool call round-tripped in-memory. + * + * The recorded id is "fixture:sampling/createMessage" (the real downstream server's + * configured name, not a placeholder) per Codex PR #29 review round 4 "preserve the + * originating server for sampling" -- writeGatewayConfig's single downstream is named + * "fixture". */ + const head = chain.readHead(stateDir); + const bundle = JSON.parse(fs.readFileSync(chain.bundlePath(stateDir, head.bundle_id), "utf8")); + const traceLines = bundle.contents["execution_trace.jsonl"].trim().split("\n").map((l) => JSON.parse(l)); + const sampleStep = traceLines.find((t) => t.tool === "fixture:sampling/createMessage"); + check( + "e2e-sampling-recorded-as-model-call-in-sealed-bundle", + // execution_trace.jsonl records only hashes of input/result (SS5.2), never plaintext + // -- assert the step exists, is attributed as a real model call, and is not an error. + sampleStep && sampleStep.model_call === true && sampleStep.is_error === false && typeof sampleStep.result_sha256 === "string" && sampleStep.result_sha256.length > 0, + traceLines.map((t) => JSON.stringify(t)).join("\n") + ); +} + +/** Codex PR #29 review round 3 "validate agent replies to pushed sampling requests": + * a reply to a gateway-pushed request that is missing "jsonrpc": "2.0" and carries + * neither "result" nor "error" must be rejected as malformed, not resolved as a + * successful `undefined` sampling result and forwarded to the downstream as success. */ +async function malformedPushedReplyIsRejectedNotSilentlyAccepted() { + const root = freshRoot("sampling-malformed-reply"); + writeConfirmedMode(root, "standalone"); + const { configPath } = writeGatewayConfig(root); + const gw = spawnGateway(root, configPath); + + gw.send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { clientInfo: { name: "test-agent", version: "1.0" } } }); + await gw.nextMessage(); + gw.send({ jsonrpc: "2.0", method: "notifications/initialized" }); + + gw.send({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "fixture_sample", arguments: { prompt: "hello from downstream" } } }); + + const pushed = await gw.nextMessage(); + check( + "e2e-malformed-reply-setup-sampling-request-pushed", + Boolean(pushed && pushed.method === "sampling/createMessage" && pushed.id !== undefined && pushed.id !== null), + JSON.stringify(pushed) + ); + + // Malformed reply: right id, no "jsonrpc", and neither "result" nor "error". + gw.send({ id: pushed && pushed.id }); + + const toolResp = await gw.nextMessage(); + check( + "e2e-malformed-pushed-reply-surfaces-as-tool-call-error-not-fake-success", + Boolean(toolResp && toolResp.id === 2 && toolResp.error && typeof toolResp.error.message === "string" && !/mocked/.test(JSON.stringify(toolResp))), + JSON.stringify(toolResp) + ); + + gw.child.stdin.end(); + const code = await gw.exitCode(); + check("e2e-malformed-pushed-reply-clean-disconnect-exits-zero", code === 0, `exit code ${code}; stderr: ${gw.stderr()}`); +} + +/** CodeRabbit PR #29 review round 4 "a reply with a present but falsy error resolves as + * a successful undefined result": a pushed-reply of the well-formed SHAPE + * {"jsonrpc":"2.0","id":..., "error":null} passes the exactly-one-of-result-or-error + * check (it HAS an "error" key), but branching on msg.error's truthiness afterward + * treated it as "no error" and resolved undefined as a fake success -- silently turning + * an agent-signaled failure into a tool call that looks like it succeeded. */ +async function pushedReplyWithPresentButFalsyErrorIsRejected() { + const root = freshRoot("sampling-falsy-error-reply"); + writeConfirmedMode(root, "standalone"); + const { configPath } = writeGatewayConfig(root); + const gw = spawnGateway(root, configPath); + + gw.send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { clientInfo: { name: "test-agent", version: "1.0" } } }); + await gw.nextMessage(); + gw.send({ jsonrpc: "2.0", method: "notifications/initialized" }); + + gw.send({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "fixture_sample", arguments: { prompt: "hello from downstream" } } }); + + const pushed = await gw.nextMessage(); + check( + "e2e-falsy-error-reply-setup-sampling-request-pushed", + Boolean(pushed && pushed.method === "sampling/createMessage" && pushed.id !== undefined && pushed.id !== null), + JSON.stringify(pushed) + ); + + // Well-formed per the "exactly one of result/error PRESENT" check -- "error" is a real + // own key -- but its VALUE is falsy (null). Must still be treated as an error reply. + gw.send({ jsonrpc: "2.0", id: pushed && pushed.id, error: null }); + + const toolResp = await gw.nextMessage(); + check( + "e2e-falsy-error-reply-surfaces-as-tool-call-error-not-fake-success", + Boolean(toolResp && toolResp.id === 2 && toolResp.error && typeof toolResp.error.message === "string" && !/"sampled"/.test(JSON.stringify(toolResp))), + JSON.stringify(toolResp) + ); + + gw.child.stdin.end(); + const code = await gw.exitCode(); + check("e2e-falsy-error-reply-clean-disconnect-exits-zero", code === 0, `exit code ${code}; stderr: ${gw.stderr()}`); +} + +/** Board decision 2026-09-04 (PR #29 review, Decision 1, Option B): when the agent + * transport is HTTP, a downstream's sampling request must get back a real JSON-RPC + * error naming why -- never the silent drop this was before the fix, and never a hang. */ +async function samplingOverHttpAgentGetsExplicitError() { + const root = freshRoot("sampling-http"); + writeConfirmedMode(root, "standalone"); + const tokenPath = path.join(root, "agent-token.txt"); + fs.writeFileSync(tokenPath, "a-fake-but-long-enough-bearer-token-value"); + const { configPath } = writeGatewayConfig(root, { agent_listen: { transport: "http", token_ref: tokenPath } }); + const gw = spawnGateway(root, configPath); + + const port = await waitForHttpPort(gw); + const token = fs.readFileSync(tokenPath, "utf8").trim(); + + /* Session identity on the agent-facing HTTP transport is an explicit, server-minted + * `Mcp-Session-Id` (board decision 2026-09-08) -- NOT TCP socket identity, so this + * test deliberately does NOT rely on connection/keep-alive reuse to make these two + * requests share one logical session (that dependency is exactly what the prior + * design got wrong: it made this test's outcome depend on http.globalAgent's + * keepAlive default, which differs across Node versions). No `agent` option is + * passed at all -- each request may or may not reuse a socket; either way, the + * session ID captured from `initialize`'s response header is what ties them + * together. */ + const initResp = await httpPost(port, token, { jsonrpc: "2.0", id: 1, method: "initialize", params: { clientInfo: { name: "test-agent-http", version: "1.0" } } }); + check("e2e-sampling-http-initialize-responds", initResp.body && initResp.body.result, JSON.stringify(initResp.body)); + const sessionId = initResp.headers["mcp-session-id"]; + check("e2e-sampling-http-initialize-returns-session-id", typeof sessionId === "string" && sessionId.length > 0, JSON.stringify(initResp.headers)); + await httpNotify(port, token, { jsonrpc: "2.0", method: "notifications/initialized" }, undefined, sessionId); + + const callResp = await httpPost(port, token, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "fixture_sample", arguments: { prompt: "hi" } } }, undefined, sessionId); + check( + "e2e-sampling-over-http-agent-gets-explicit-error-not-silence", + callResp.body && callResp.body.id === 2 && callResp.body.error && /stdio/i.test(callResp.body.error.message), + JSON.stringify(callResp.body) + ); + + gw.child.kill(); + await gw.exitCode(); +} + +/** Board decision 2026-09-04, PR #29 review "send stateless metadata to HTTP + * downstreams": the fixture used by every other test in this suite is permissive and + * never exercised the real, stricter contract this repo's OWN in-repo MCP HTTP server + * enforces (mcp-server/src/server.js's validateMeta: every tools/list and tools/call + * over HTTP needs a fresh, complete _meta block, since connectionState is per-request + * and never remembers an earlier `initialize`). Runs the real mcp-server HTTP transport + * as the gateway's configured downstream and proves the full handshake, tools/list, and + * tools/call all succeed against it -- not just against the lenient fixture. */ +async function httpDownstreamAgainstRealMcpServerSucceeds() { + const root = freshRoot("http-downstream-real-server"); + writeConfirmedMode(root, "standalone"); + const { createHttpServer } = require(path.join(ROOT, "mcp-server", "src", "httpTransport.js")); + const downstreamToken = "a-fake-but-long-enough-downstream-bearer-token"; + const downstreamServer = createHttpServer({ token: downstreamToken }); + await new Promise((resolve) => downstreamServer.listen(0, resolve)); + const downstreamPort = downstreamServer.address().port; + const downstreamTokenPath = path.join(root, "downstream-token.txt"); + fs.writeFileSync(downstreamTokenPath, downstreamToken); + + const { configPath } = writeGatewayConfig(root, { + downstream_servers: [{ name: "real-mcp", transport: "http", endpoint: `http://127.0.0.1:${downstreamPort}`, token_ref: downstreamTokenPath }], + }); + const gw = spawnGateway(root, configPath); + + gw.send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { clientInfo: { name: "test-agent", version: "1.0" } } }); + const initResp = await gw.nextMessage(); + check("e2e-http-downstream-initialize-responds", initResp && initResp.result, JSON.stringify(initResp)); + gw.send({ jsonrpc: "2.0", method: "notifications/initialized" }); + + gw.send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); + const toolsResp = await gw.nextMessage(); + const toolNames = toolsResp && toolsResp.result && Array.isArray(toolsResp.result.tools) && toolsResp.result.tools.map((t) => t.name); + check( + "e2e-http-downstream-tools-list-succeeds-against-real-mcp-server", + Array.isArray(toolNames) && toolNames.includes("graphsmith_guidance"), + JSON.stringify(toolsResp) + ); + + gw.send({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "graphsmith_guidance", arguments: {} } }); + const callResp = await gw.nextMessage(); + check("e2e-http-downstream-tools-call-succeeds-against-real-mcp-server", callResp && callResp.result && !callResp.error, JSON.stringify(callResp)); + + gw.child.stdin.end(); + await gw.exitCode(); + await new Promise((resolve) => downstreamServer.close(resolve)); +} + +/** Board decision 2026-09-04, PR #29 review "reject non-POST requests on the agent HTTP + * listener": a GET carrying a JSON-RPC body must be refused with 405 before + * authentication or dispatch, mirroring mcp-server/src/httpTransport.js's own contract + * for its listener. */ +async function agentHttpListenerRejectsNonPostMethod() { + const root = freshRoot("agent-http-non-post"); + writeConfirmedMode(root, "standalone"); + const tokenPath = path.join(root, "agent-token.txt"); + fs.writeFileSync(tokenPath, "a-fake-but-long-enough-bearer-token-value"); + const { configPath } = writeGatewayConfig(root, { agent_listen: { transport: "http", token_ref: tokenPath } }); + const gw = spawnGateway(root, configPath); + const port = await waitForHttpPort(gw); + + const status = await new Promise((resolve, reject) => { + const req = http.request({ host: "127.0.0.1", port, method: "GET" }, (res) => { + res.resume(); + resolve(res.statusCode); + }); + req.on("error", reject); + req.end(); + }); + check("e2e-agent-http-listener-rejects-get-with-405", status === 405, String(status)); + + gw.child.kill(); + await gw.exitCode(); +} + +/** Board decision 2026-09-04, PR #29 review "reject HTTP listener bind failures through + * the startup promise": a second gateway configured to bind the SAME already-occupied + * agent_listen.port must fail its startup promise (not hang or crash uncaught) and exit + * non-zero with a clear message, mirroring secondInstanceRefused's own writer-claim + * precedent for a different resource. */ +async function agentHttpListenerBindFailureRejectedCleanly() { + const rootA = freshRoot("agent-http-bind-a"); + writeConfirmedMode(rootA, "standalone"); + const tokenPathA = path.join(rootA, "agent-token.txt"); + fs.writeFileSync(tokenPathA, "a-fake-but-long-enough-bearer-token-value"); + const { configPath: configPathA } = writeGatewayConfig(rootA, { agent_listen: { transport: "http", token_ref: tokenPathA } }); + const gwA = spawnGateway(rootA, configPathA); + const occupiedPort = await waitForHttpPort(gwA); + + const rootB = freshRoot("agent-http-bind-b"); + writeConfirmedMode(rootB, "standalone"); + const tokenPathB = path.join(rootB, "agent-token.txt"); + fs.writeFileSync(tokenPathB, "a-fake-but-long-enough-bearer-token-value"); + const { configPath: configPathB } = writeGatewayConfig(rootB, { agent_listen: { transport: "http", port: occupiedPort, token_ref: tokenPathB } }); + const gwB = spawnGateway(rootB, configPathB); + const codeB = await gwB.exitCode(); + check("e2e-agent-http-bind-failure-exits-nonzero-not-hang", codeB !== 0, `exit code ${codeB}`); + check("e2e-agent-http-bind-failure-names-listener-in-stderr", /agent-facing HTTP listener/i.test(gwB.stderr()), gwB.stderr()); + + gwA.child.kill(); + await gwA.exitCode(); +} + +/** Board decision 2026-09-08 (external panel consult, x-ai/grok-4.5's dissent adopted + * over the socket-scoped alternative): proves the actual reason that design was chosen + * over a simpler one -- two DISTINCT logical sessions sharing one physical TCP socket + * (the connection-pooling-reverse-proxy scenario this repo's own prior comments already + * flagged as an unenforced assumption) must NOT collide, cross-contaminate, or let one + * agent call tools before ITS OWN "initialize". A socket-identity design cannot pass + * this by construction; an explicit `Mcp-Session-Id` design can. Also covers the new + * transport-level session lifecycle surface directly: missing-header rejection, unknown- + * session 404, and explicit DELETE termination. */ +async function httpAgentSessionsAreIdBasedNotSocketBased() { + const root = freshRoot("agent-http-session-id"); + writeConfirmedMode(root, "standalone"); + const tokenPath = path.join(root, "agent-token.txt"); + fs.writeFileSync(tokenPath, "a-fake-but-long-enough-bearer-token-value"); + const { configPath } = writeGatewayConfig(root, { agent_listen: { transport: "http", token_ref: tokenPath } }); + const gw = spawnGateway(root, configPath); + const port = await waitForHttpPort(gw); + const token = fs.readFileSync(tokenPath, "utf8").trim(); + + /* One shared, single-socket keep-alive agent: both logical sessions below are forced + * onto the SAME underlying TCP connection. Under the prior (superseded) socket-keyed + * design this would make the second "initialize" silently reuse the first session -- + * exactly the pooling-proxy corruption this change exists to fix. */ + const sharedSocketAgent = new http.Agent({ keepAlive: true, maxSockets: 1 }); + try { + const missingHeaderResp = await httpPost(port, token, { jsonrpc: "2.0", id: 0, method: "tools/call", params: { name: "fixture_echo", arguments: {} } }, sharedSocketAgent); + check( + "e2e-agent-http-missing-session-header-non-initialize-rejected", + missingHeaderResp.body && missingHeaderResp.body.error && /mcp-session-id/i.test(missingHeaderResp.body.error.message), + JSON.stringify(missingHeaderResp.body) + ); + + const initA = await httpPost(port, token, { jsonrpc: "2.0", id: 1, method: "initialize", params: { clientInfo: { name: "agent-A", version: "1.0" } } }, sharedSocketAgent); + const sessionA = initA.headers["mcp-session-id"]; + const initB = await httpPost(port, token, { jsonrpc: "2.0", id: 1, method: "initialize", params: { clientInfo: { name: "agent-B", version: "1.0" } } }, sharedSocketAgent); + const sessionB = initB.headers["mcp-session-id"]; + check("e2e-agent-http-two-sessions-on-one-socket-get-distinct-ids", typeof sessionA === "string" && typeof sessionB === "string" && sessionA !== sessionB, JSON.stringify({ sessionA, sessionB })); + await httpNotify(port, token, { jsonrpc: "2.0", method: "notifications/initialized" }, sharedSocketAgent, sessionA); + await httpNotify(port, token, { jsonrpc: "2.0", method: "notifications/initialized" }, sharedSocketAgent, sessionB); + + const callA = await httpPost(port, token, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "fixture_echo", arguments: { prompt: "from A" } } }, sharedSocketAgent, sessionA); + check("e2e-agent-http-session-a-tools-call-succeeds", callA.body && callA.body.id === 2 && callA.body.result, JSON.stringify(callA.body)); + + const unknownResp = await httpPost(port, token, { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "fixture_echo", arguments: {} } }, sharedSocketAgent, "http-" + "0".repeat(32)); + check("e2e-agent-http-unknown-session-id-rejected-404", unknownResp.body && unknownResp.body.error && /unknown or expired/i.test(unknownResp.body.error.message), JSON.stringify(unknownResp.body)); + + await new Promise((resolve, reject) => { + const req = http.request({ host: "127.0.0.1", port, method: "DELETE", agent: sharedSocketAgent, headers: { authorization: `Bearer ${token}`, "mcp-session-id": sessionA } }, (res) => { + check("e2e-agent-http-delete-terminates-session-204", res.statusCode === 204, String(res.statusCode)); + res.resume(); + res.on("end", resolve); + }); + req.on("error", reject); + req.end(); + }); + + const afterDeleteA = await httpPost(port, token, { jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "fixture_echo", arguments: {} } }, sharedSocketAgent, sessionA); + check("e2e-agent-http-deleted-session-now-unknown", afterDeleteA.body && afterDeleteA.body.error && /unknown or expired/i.test(afterDeleteA.body.error.message), JSON.stringify(afterDeleteA.body)); + + const callB = await httpPost(port, token, { jsonrpc: "2.0", id: 5, method: "tools/call", params: { name: "fixture_echo", arguments: { prompt: "from B" } } }, sharedSocketAgent, sessionB); + check("e2e-agent-http-session-b-unaffected-by-session-a-deletion", callB.body && callB.body.id === 5 && callB.body.result, JSON.stringify(callB.body)); + } finally { + sharedSocketAgent.destroy(); + } + + gw.child.kill(); + await gw.exitCode(); +} + +/** Codex PR #29 review "validate initialize before allocating an HTTP session": a + * headerless request whose method is "initialize" but whose envelope is otherwise + * malformed (missing "jsonrpc": "2.0" here) previously still got a real session with a + * 30-minute idle timer before proxy.handleMessage's own envelope check rejected it. The + * session must instead be sealed immediately, so a later call against that same session + * id sees it as unknown, not merely inert until the idle timeout. */ +async function httpFailedInitializeDoesNotLeakSession() { + const root = freshRoot("agent-http-failed-init"); + writeConfirmedMode(root, "standalone"); + const tokenPath = path.join(root, "agent-token.txt"); + fs.writeFileSync(tokenPath, "a-fake-but-long-enough-bearer-token-value"); + const { configPath, stateDir } = writeGatewayConfig(root, { agent_listen: { transport: "http", token_ref: tokenPath } }); + const gw = spawnGateway(root, configPath); + const port = await waitForHttpPort(gw); + const token = fs.readFileSync(tokenPath, "utf8").trim(); + const agent = new http.Agent({ keepAlive: true }); + try { + // No "jsonrpc": "2.0" -- fails proxy.js's own envelope check, but agent-transport.js's + // pre-session gate only looks at msg.method, so this still reaches openSession(). + const badInit = await httpPost(port, token, { id: 1, method: "initialize", params: {} }, agent); + const leakedSessionId = badInit.headers["mcp-session-id"]; + check( + "e2e-agent-http-failed-initialize-returns-error", + Boolean(badInit.body && badInit.body.error), + JSON.stringify(badInit.body) + ); + check("e2e-agent-http-failed-initialize-still-names-a-session-id", typeof leakedSessionId === "string", JSON.stringify(badInit.headers)); + if (typeof leakedSessionId === "string") { + const followUp = await httpPost(port, token, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "fixture_echo", arguments: {} } }, agent, leakedSessionId); + check( + "e2e-agent-http-session-from-failed-initialize-is-sealed-not-leaked", + followUp.body && followUp.body.error && /unknown or expired/i.test(followUp.body.error.message), + JSON.stringify(followUp.body) + ); + /* CodeRabbit PR #29 review round 8 "verify that the failed session was persisted": + * the assertion above only proves session REMOVAL (the follow-up sees an unknown + * session) -- it does not prove session SEALING. An implementation that deletes the + * session without ever writing its bundle would pass it just the same. This session + * is the only one this test ever opens, so the chain's head (mirrors + * singleSessionEndToEndVerifies' own "e2e-exactly-one-bundle-written" pattern + * elsewhere in this file) must name exactly it, and that bundle must actually exist + * on disk, not merely be referenced. */ + const head = chain.readHead(stateDir); + check("e2e-failed-initialize-session-is-recorded-in-the-chain", Boolean(head && head.seq === 1 && typeof head.bundle_id === "string"), JSON.stringify(head)); + if (head && typeof head.bundle_id === "string") { + check( + "e2e-failed-initialize-session-bundle-actually-exists-on-disk", + fs.existsSync(chain.bundlePath(stateDir, head.bundle_id)), + chain.bundlePath(stateDir, head.bundle_id) + ); + } + } + } finally { + agent.destroy(); + } + gw.child.kill(); + await gw.exitCode(); +} + +async function modeDormantExitsZero() { + const root = freshRoot("dormant"); + writeConfirmedMode(root, "attach"); + const { configPath, stateDir } = writeGatewayConfig(root); + const gw = spawnGateway(root, configPath); + gw.child.stdin.end(); + const code = await gw.exitCode(); + check("e2e-attach-mode-standalone-binary-exits-zero", code === 0, `exit code ${code}; stderr: ${gw.stderr()}`); + check("e2e-attach-mode-mentions-dormant-in-stderr", /dormant/i.test(gw.stderr()), gw.stderr()); + check("e2e-attach-mode-never-created-state-dir", !fs.existsSync(stateDir), "state_dir was created despite dormant mode"); +} + +async function secondInstanceRefused() { + const root = freshRoot("second-instance"); + writeConfirmedMode(root, "standalone"); + const { configPath } = writeGatewayConfig(root); + + const first = spawnGateway(root, configPath); + first.send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); + await first.nextMessage(); // wait until the first instance is fully up (claim held) before racing the second + + const second = spawnGateway(root, configPath); + const secondCode = await second.exitCode(); + check("e2e-second-instance-refused-nonzero-exit", secondCode !== 0, `exit code ${secondCode}`); + check("e2e-second-instance-error-names-writer-claim", /single-writer constraint/i.test(second.stderr()) && /writer-claim/i.test(second.stderr()), second.stderr()); + + first.child.kill(); // best-effort cleanup; not testing graceful shutdown here (see test 5) + await first.exitCode(); +} + +async function cleanSigtermDrainsAndExitsZero() { + if (process.platform === "win32") { + skip("e2e-sigterm-clean-drain-and-release", "Windows cannot deliver a real SIGTERM for graceful in-process handling (child.kill('SIGTERM') force-terminates on win32) -- mirrors this repo's existing writer-claim precedent of skipping a platform-unreproducible signal test rather than writing one that cannot mean what it claims."); + return; + } + const root = freshRoot("sigterm"); + writeConfirmedMode(root, "standalone"); + const { configPath, stateDir } = writeGatewayConfig(root); + const gw = spawnGateway(root, configPath); + + /* Board decision 2026-09-04, PR #29 review "enforce the agent initialization + * lifecycle": tools/call and tools/list are now both rejected before a connection has + * completed initialize, so this test (previously skipping it to focus purely on + * SIGTERM-drain behavior) must complete a real handshake first. Awaited on its own + * before the two sends below, so it does not disturb their own same-tick ordering + * guarantee (see the comment on that below). */ + gw.send({ jsonrpc: "2.0", id: 0, method: "initialize", params: {} }); + const initResp = await gw.nextMessage(); + check("e2e-sigterm-preinitialize-succeeded", initResp && initResp.id === 0 && initResp.result, JSON.stringify(initResp)); + gw.send({ jsonrpc: "2.0", method: "notifications/initialized" }); + + gw.send({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "fixture_echo", arguments: { delayMs: 200 } } }); + /* Explicit call-start signal, not a fixed sleep: tools/list needs no downstream round- + * trip (it's answered straight from the cached tool surface), so sending it right + * behind the slow call and awaiting ITS response proves the slow call's handleMessage() + * already ran synchronously up to (and including) session.recordCallStart() -- i.e. it + * is genuinely pending -- before this test signals. Readline dispatches "line" events, + * and therefore these two handleMessage() invocations, strictly in the order the lines + * were written, so this ordering holds regardless of scheduler/startup latency (the + * flakiness a fixed sleep was exposed to: a 50ms sleep can fire before the gateway has + * even read its first line under CI scheduling pressure). */ + gw.send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); + const readinessResp = await gw.nextMessage(); + check("e2e-sigterm-readiness-signal-received-before-sending-it", readinessResp && readinessResp.id === 2, JSON.stringify(readinessResp)); + gw.child.kill("SIGTERM"); + const callResp = await gw.nextMessage(5000); + check("e2e-sigterm-in-flight-call-still-completes-during-drain", callResp && callResp.id === 1 && callResp.result, JSON.stringify(callResp)); + const code = await gw.exitCode(); + check("e2e-sigterm-clean-drain-and-release-exits-zero", code === 0, `exit code ${code}; stderr: ${gw.stderr()}`); + const head = chain.readHead(stateDir); + check("e2e-sigterm-session-still-finalized-and-persisted", head && head.seq === 1, JSON.stringify(head)); +} + +/* Cluster E (partial fix): checkModeGate's root must track configPath's own directory, + * not process.cwd() -- `gateway.js --config /path/to/project-b/gateway.json` run with + * cwd somewhere else entirely must still validate project B's own + * .graphsmith/gateway-mode.json, not whatever (if anything) sits under cwd. Spawns the + * CLI with cwd deliberately set to a directory that has NO .graphsmith/ at all, passing + * an ABSOLUTE configPath pointing into a completely different, properly-mode-confirmed + * project root -- before this fix, checkModeGate would have looked for + * /.graphsmith/gateway-mode.json, found nothing, and refused to start. */ +async function modeGateRootTracksConfigPathNotCwd() { + const projectRoot = freshRoot("mode-root-project"); + writeConfirmedMode(projectRoot, "standalone"); + const { configPath } = writeGatewayConfig(projectRoot); + + const unrelatedCwd = freshRoot("mode-root-elsewhere"); + check("e2e-mode-gate-root-elsewhere-cwd-has-no-dot-graphsmith", !fs.existsSync(path.join(unrelatedCwd, ".graphsmith")), "test setup invariant violated"); + + const child = spawn(process.execPath, [GATEWAY_CLI, configPath], { cwd: unrelatedCwd, stdio: ["pipe", "pipe", "pipe"] }); + const rl = readline.createInterface({ input: child.stdout, terminal: false }); + const firstLine = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`timed out waiting for a response; stderr so far: ${stderr}`)), 10000); + rl.once("line", (line) => { clearTimeout(timer); resolve(line); }); + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); }); + + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }) + "\n"); + let initResp = null; + try { + initResp = JSON.parse(await firstLine); + } catch (error) { + // fall through with initResp === null; the check below reports stderr for diagnosis + } + check( + "e2e-mode-gate-root-derived-from-configpath-not-cwd", + Boolean(initResp && initResp.id === 1 && initResp.result), + `initResp=${JSON.stringify(initResp)}; stderr=${stderr}` + ); + child.kill(); + await new Promise((resolve) => child.on("close", resolve)); +} + +/* Cluster D: `gateway.js status ` reads the status file a running gateway + * periodically writes and pretty-prints it -- exercised here as a genuinely separate CLI + * invocation (not an in-process call), matching how an operator would actually use it. */ +async function statusCommandReadsRunningGatewaysStatusFile() { + const root = freshRoot("status-cmd"); + writeConfirmedMode(root, "standalone"); + const { configPath } = writeGatewayConfig(root); + const gw = spawnGateway(root, configPath); + gw.send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); + const initResp = await gw.nextMessage(); + check("e2e-status-command-gateway-initialized", initResp && initResp.id === 1 && initResp.result, JSON.stringify(initResp)); + + // writeStatusFile() runs once synchronously at startup (before the first interval + // tick) specifically so a `status` call right after start already finds fresh data -- + // no polling/sleep needed here. + const statusRun = spawn(process.execPath, [GATEWAY_CLI, "status", configPath], { cwd: root, stdio: ["ignore", "pipe", "pipe"] }); + let statusStdout = ""; + let statusStderr = ""; + statusRun.stdout.on("data", (c) => { statusStdout += c.toString("utf8"); }); + statusRun.stderr.on("data", (c) => { statusStderr += c.toString("utf8"); }); + const statusCode = await new Promise((resolve) => statusRun.on("close", resolve)); + check("e2e-status-command-exits-zero-while-gateway-running", statusCode === 0, `exit ${statusCode}; stderr: ${statusStderr}`); + + let parsed = null; + try { parsed = JSON.parse(statusStdout); } catch (error) { /* leave null; checked below */ } + check("e2e-status-command-prints-valid-json", parsed !== null, statusStdout); + check( + "e2e-status-command-reports-writer-claim-held-and-one-active-session", + Boolean(parsed && parsed.writer_claim && parsed.writer_claim.held_by_this_instance === true && parsed.active_sessions === 1), + JSON.stringify(parsed) + ); + check("e2e-status-command-reports-schema-version", parsed && parsed.schema_version === "1.0", JSON.stringify(parsed)); + + gw.child.kill(); + await gw.exitCode(); + + // No gateway has ever run against THIS config -> no status file yet -> clear, + // non-zero-exit error rather than a stack trace. + const freshCfgRoot = freshRoot("status-cmd-none-yet"); + writeConfirmedMode(freshCfgRoot, "standalone"); + const { configPath: freshConfigPath } = writeGatewayConfig(freshCfgRoot); + const noStatusRun = spawn(process.execPath, [GATEWAY_CLI, "status", freshConfigPath], { cwd: freshCfgRoot, stdio: ["ignore", "ignore", "pipe"] }); + let noStatusStderr = ""; + noStatusRun.stderr.on("data", (c) => { noStatusStderr += c.toString("utf8"); }); + const noStatusCode = await new Promise((resolve) => noStatusRun.on("close", resolve)); + check("e2e-status-command-nonzero-exit-when-no-status-file-yet", noStatusCode !== 0, `exit ${noStatusCode}`); + check("e2e-status-command-names-the-config-when-no-status-file-yet", /gateway-status\.json/.test(noStatusStderr), noStatusStderr); +} + +async function main() { + await singleSessionEndToEndVerifies(); + await samplingForwardedToStdioAgent(); + await malformedPushedReplyIsRejectedNotSilentlyAccepted(); + await pushedReplyWithPresentButFalsyErrorIsRejected(); + await samplingOverHttpAgentGetsExplicitError(); + await httpDownstreamAgainstRealMcpServerSucceeds(); + await agentHttpListenerRejectsNonPostMethod(); + await agentHttpListenerBindFailureRejectedCleanly(); + await httpAgentSessionsAreIdBasedNotSocketBased(); + await httpFailedInitializeDoesNotLeakSession(); + await modeDormantExitsZero(); + await secondInstanceRefused(); + await cleanSigtermDrainsAndExitsZero(); + await modeGateRootTracksConfigPathNotCwd(); + await statusCommandReadsRunningGatewaysStatusFile(); + + const passed = results.filter((r) => r.status === "PASS").length; + const failed = results.filter((r) => r.status === "FAIL").length; + const skipped = results.filter((r) => r.status === "SKIP").length; + console.log(`SUMMARY passed=${passed} failed=${failed} skipped=${skipped}`); + process.exit(failures ? 1 : 0); +} + +main().catch((error) => { + console.error("FATAL:", error.stack || error.message); + process.exit(1); +}); diff --git a/tests/gateway/lifecycle/run-tests.js b/tests/gateway/lifecycle/run-tests.js new file mode 100644 index 00000000..e8dea613 --- /dev/null +++ b/tests/gateway/lifecycle/run-tests.js @@ -0,0 +1,280 @@ +#!/usr/bin/env node +"use strict"; + +/* Regression suite for the graceful-shutdown DRAIN behavior (Standalone Gateway TRD + * SS3.7/SS8 test 5), at the unit level -- against scripts/gateway/gateway.js's + * drainOpenSessions() directly with a fake slow downstream connection, NOT via a real + * OS SIGTERM. This is deliberate: Windows cannot deliver a real SIGTERM for graceful + * in-process handling (child.kill('SIGTERM') force-terminates there), so + * tests/gateway/e2e's own SIGTERM test is skipped on win32 -- this suite instead proves + * the actual drain LOGIC cross-platform, independent of OS signal delivery. The real + * end-to-end proof (a genuine SIGTERM to a real child process) still runs on any + * non-Windows CI runner via tests/gateway/e2e/run-tests.js. + */ + +const crypto = require("crypto"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const ROOT = path.resolve(__dirname, "../../.."); +const { drainOpenSessions, startGateway } = require(path.join(ROOT, "scripts", "gateway", "gateway.js")); +const { GatewayProxy } = require(path.join(ROOT, "scripts", "gateway", "proxy.js")); +const { WriterClaim } = require(path.join(ROOT, "scripts", "writer-claim.js")); +const { writeConfirmedMode } = require("../_fixtures/mode-file.js"); + +let failures = 0; +const results = []; +function record(name, status, reason) { + console.log(status === "PASS" ? `PASS ${name}` : `FAIL ${name}+${reason || "unknown"}`); + results.push({ name, status, reason: reason || "" }); + if (status === "FAIL") failures++; +} +function check(name, cond, reason) { + record(name, cond ? "PASS" : "FAIL", reason); +} + +function freshDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), `gs-gateway-lifecycle-${prefix}-`)); +} +function makeKeys() { + const kp = crypto.generateKeyPairSync("ed25519"); + return { privateKey: kp.privateKey, signer: "test-key", algo: "ed25519" }; +} + +/** A downstream connection whose call resolves after `delayMs`. */ +function delayedConnection(delayMs, result) { + return { + transport: "fake", + call: () => new Promise((resolve) => setTimeout(() => resolve(result), delayMs)), + close: () => {}, + isClosed: () => false, + whenClosed: () => new Promise(() => {}), + }; +} + +async function drainWaitsForInFlightCallToComplete() { + const dir = freshDir("waits"); + const conn = delayedConnection(150, { ok: true, marker: "real-result" }); + const proxy = new GatewayProxy({ + connections: new Map([["srv", conn]]), + mergedTools: [{ name: "slow", server: "srv", schema: {} }], + toolOwners: new Map([["slow", "srv"]]), + serverInfos: {}, + keys: makeKeys(), + stateDir: dir, + }); + proxy.openConnection("conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 0, method: "initialize", params: {} }); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", method: "notifications/initialized" }); + const callPromise = proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "slow", arguments: {} } }); + + const drained = await drainOpenSessions(proxy, 2000, 10); + check("drain-reports-clean-when-call-finishes-in-time", drained === true, String(drained)); + + const response = await callPromise; + check("drained-call-still-completed-with-real-result", response.result && response.result.marker === "real-result", JSON.stringify(response)); + + const s = proxy.sessions.get("conn-1"); + check("session-has-no-pending-calls-after-drain", s.pendingCalls.size === 0, String(s.pendingCalls.size)); + const entry = await proxy.closeConnection("conn-1", "test cleanup"); + const bundle = JSON.parse(fs.readFileSync(require(path.join(ROOT, "scripts", "gateway", "chain.js")).bundlePath(dir, entry.bundle_id), "utf8")); + check("finalized-bundle-shows-the-call-as-NOT-an-error", /"is_error":false/.test(bundle.contents["execution_trace.jsonl"]), bundle.contents["execution_trace.jsonl"]); +} + +async function drainTimesOutAndReportsIncomplete() { + const dir = freshDir("timeout"); + const conn = delayedConnection(5000, { ok: true }); // far longer than the drain budget below + const proxy = new GatewayProxy({ + connections: new Map([["srv", conn]]), + mergedTools: [{ name: "veryslow", server: "srv", schema: {} }], + toolOwners: new Map([["veryslow", "srv"]]), + serverInfos: {}, + keys: makeKeys(), + stateDir: dir, + }); + proxy.openConnection("conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 0, method: "initialize", params: {} }); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", method: "notifications/initialized" }); + proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "veryslow", arguments: {} } }); // fire and forget + + const drained = await drainOpenSessions(proxy, 100, 10); // budget far shorter than the call + check("drain-reports-incomplete-on-timeout", drained === false, String(drained)); + + // After a timed-out drain, gateway.js's stop() proceeds to closeConnection anyway, + // which must mark the still-pending call disconnected rather than hang forever. + const entry = await proxy.closeConnection("conn-1", "drain timeout exceeded"); + check("timed-out-drain-still-produces-a-finalized-bundle", entry !== null, "no entry appended"); +} + +async function drainWithNoOpenSessionsReturnsImmediately() { + const dir = freshDir("none-open"); + const proxy = new GatewayProxy({ connections: new Map(), mergedTools: [], toolOwners: new Map(), serverInfos: {}, keys: makeKeys(), stateDir: dir }); + const start = Date.now(); + const drained = await drainOpenSessions(proxy, 5000, 10); + const elapsed = Date.now() - start; + check("drain-with-no-sessions-returns-true", drained === true, String(drained)); + check("drain-with-no-sessions-returns-fast", elapsed < 500, `took ${elapsed}ms`); +} + +/* Codex PR #29 review round 8 "install shutdown handlers before gateway startup + * completes": main() only registers SIGTERM/SIGINT AFTER startGateway() resolves, so a + * termination received while a slow downstream handshake is still in progress previously + * fell through to Node's default immediate-exit behavior, skipping cleanup of an + * already-acquired writer-claim. Rather than move handler registration earlier (the + * panel-rejected approach -- see STARTUP_DOWNSTREAM_CONNECT_TIMEOUT_MS's own header + * comment in gateway.js), startGateway() now bounds the slow step itself: a downstream + * that spawns but never completes its handshake makes startup fail cleanly instead of + * hanging forever, through the SAME failure path (writerClaim.release() + reject) an + * outright connection refusal already uses. Uses a real hung child process (a `node -e` + * one-liner that spawns and idles forever, never speaking JSON-RPC), not a fake, since + * this exercises connectAllDownstreams' real spawn/handshake path -- but calls + * startGateway() directly (not the CLI) with a short startupDownstreamConnectTimeoutMs + * override so the test runs in well under a second rather than waiting out the real + * production default (mirrors drainTimeoutMs/statusWriteIntervalMs's own existing + * options-override pattern in this same function). */ +async function startupWatchdogTimesOutOnHungDownstreamConnect() { + const root = freshDir("startup-watchdog"); + writeConfirmedMode(root, "standalone"); + const kp = crypto.generateKeyPairSync("ed25519"); + const keyPath = path.join(root, "signing-key.pem"); + fs.writeFileSync(keyPath, kp.privateKey.export({ type: "pkcs8", format: "pem" }).toString()); + const stateDir = path.join(root, "state"); + // connectStdio splits its endpoint on whitespace with no shell (see its own header + // comment) -- an inline `node -e "..."` one-liner containing spaces/parens would be + // split apart wrong, so this spawns a tiny real script file instead, matching the + // existing fixture-server convention (`node [args]`, no embedded shell syntax). + const hangScriptPath = path.join(root, "hangs-forever.js"); + const hangPidPath = path.join(root, "hangs-forever.pid"); + // Writes its own pid before idling so this test can reap it afterward (see the + // try/finally below) -- this scenario is EXACTLY "a downstream that never completes its + // handshake" (the panel deliberately did not ask this fix to safely tear down + // partially-initialized startup state -- see this test's own header comment), so nothing + // inside startGateway()/connectAllDownstreams itself kills this child; only this test's + // own cleanup does, so it doesn't leave an orphan idling on the machine after the test. + fs.writeFileSync(hangScriptPath, `require("fs").writeFileSync(${JSON.stringify(hangPidPath)}, String(process.pid)); setInterval(() => {}, 1 << 30);\n`); + const config = { + schema_version: "1.0", + state_dir: stateDir, + // Spawns successfully (so connectAllDownstreams gets a live child) but never speaks a + // word of JSON-RPC -- its "initialize" call hangs forever, exactly the "slow + // downstream handshake in progress" scenario this watchdog exists to bound. + downstream_servers: [{ name: "hangs", transport: "stdio", endpoint: `${process.execPath} ${hangScriptPath}` }], + signing_key_ref: keyPath, + }; + const configPath = path.join(root, "gateway-config.json"); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + + const start = Date.now(); + let thrown = null; + try { + await startGateway({ configPath, startupDownstreamConnectTimeoutMs: 200 }); + } catch (error) { + thrown = error; + } + const elapsed = Date.now() - start; + + check("startup-watchdog-fails-closed-rather-than-hanging-forever", thrown !== null, "startGateway resolved instead of rejecting"); + check("startup-watchdog-error-names-the-timeout", Boolean(thrown && thrown.code === "GATEWAY_STARTUP_TIMEOUT"), thrown && thrown.message); + check("startup-watchdog-fires-within-a-bounded-window-not-indefinitely", elapsed < 5000, `took ${elapsed}ms`); + + // The SAME startup-failure cleanup path a genuine connect failure already uses must + // still have released the writer-claim -- otherwise a timed-out startup would leave a + // stale claim blocking the next start attempt, exactly the failure mode this fix exists + // to avoid. status().claimed reads fresh from disk and is instance-independent (unlike + // held_by_this_instance, which only ever answers for the SAME WriterClaim instance that + // acquired it), so a fresh WriterClaim here can honestly observe whether the on-disk + // claim file was actually removed. + const claimStatus = new WriterClaim(stateDir).status(); + check("startup-watchdog-failure-releases-the-writer-claim", claimStatus.claimed === false, JSON.stringify(claimStatus)); + + // Reap the hung child this test itself spawned (see hangScriptPath's own comment) -- + // best effort, polling briefly since the pidfile write races this test's own assertions. + for (let i = 0; i < 20 && !fs.existsSync(hangPidPath); i++) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + try { + const pid = parseInt(fs.readFileSync(hangPidPath, "utf8"), 10); + if (Number.isInteger(pid)) process.kill(pid, "SIGKILL"); + } catch (error) { + // best effort only -- an already-dead or never-spawned process is not this test's + // own assertion to make. + } +} + +/* Codex PR #29 review round 8 "cap completed downstream sampling history": proxy.js's own + * agent-initiated dispatch enforces MAX_COMPLETED_CALLS_PER_SESSION, but this SEPARATE + * downstream-initiated sampling path (forwardDownstreamRequestToAgent, exercised + * end-to-end via a real downstream in tests/gateway/e2e's own sampling tests) never + * consulted it -- a sampling-capable stdio downstream issuing requests sequentially could + * grow session.calls without bound. Exercised here at the unit level, against a fake + * session/proxy/agentPusher, specifically to prove the ADMISSION REFUSAL at the cap -- + * driving session.calls up to the real cap via genuine round trips would be far too slow + * for a unit suite (same rationale proxy/run-tests.js's own completedCallHistoryCapped + * test already documents for the agent-initiated case). */ +async function samplingForwardRefusesOnceCompletedCallCapReached() { + const { forwardDownstreamRequestToAgent } = require(path.join(ROOT, "scripts", "gateway", "gateway.js")); + const { MAX_COMPLETED_CALLS_PER_SESSION } = require(path.join(ROOT, "scripts", "gateway", "proxy.js")); + const sessionModule = require(path.join(ROOT, "scripts", "gateway", "session.js")); + const s = sessionModule.createSession("agent-conn-1"); + s.calls.length = MAX_COMPLETED_CALLS_PER_SESSION; // cheap stand-in for that many genuinely-completed calls + const proxy = { sessions: new Map([["agent-conn-1", s]]), now: () => Date.now() }; + let agentWasAsked = false; + const agentPusher = { current: () => { agentWasAsked = true; return Promise.resolve({}); }, connectionId: "agent-conn-1" }; + + const resp = await forwardDownstreamRequestToAgent( + { jsonrpc: "2.0", id: 7, method: "sampling/createMessage", params: {} }, + agentPusher, + () => {}, + proxy, + "srv" + ); + check( + "sampling-forward-refuses-once-completed-call-cap-reached", + Boolean(resp.error && resp.error.code === -32000 && /already completed/.test(resp.error.message)), + JSON.stringify(resp) + ); + check("sampling-forward-does-not-reach-the-agent-once-refused", agentWasAsked === false, String(agentWasAsked)); + check("sampling-forward-cap-refusal-does-not-grow-session-calls", s.calls.length === MAX_COMPLETED_CALLS_PER_SESSION, String(s.calls.length)); +} + +/* Same cap, exercised well BELOW it: a session with room left must still have its + * downstream-initiated sampling request forwarded to the agent as normal -- proving the + * new check in samplingForwardRefusesOnceCompletedCallCapReached's own fix is a genuine + * admission gate, not an accidental blanket refusal. */ +async function samplingForwardStillWorksBelowTheCap() { + const { forwardDownstreamRequestToAgent } = require(path.join(ROOT, "scripts", "gateway", "gateway.js")); + const sessionModule = require(path.join(ROOT, "scripts", "gateway", "session.js")); + const s = sessionModule.createSession("agent-conn-2"); + const proxy = { sessions: new Map([["agent-conn-2", s]]), now: () => Date.now() }; + const agentPusher = { current: () => Promise.resolve({ role: "assistant", content: { type: "text", text: "ok" } }), connectionId: "agent-conn-2" }; + + const resp = await forwardDownstreamRequestToAgent( + { jsonrpc: "2.0", id: 8, method: "sampling/createMessage", params: {} }, + agentPusher, + () => {}, + proxy, + "srv" + ); + check("sampling-forward-succeeds-below-the-cap", Boolean(resp.result && resp.result.content && resp.result.content.text === "ok"), JSON.stringify(resp)); + check("sampling-forward-records-the-completed-call-below-the-cap", s.calls.length === 1 && s.calls[0].model_call === true, JSON.stringify(s.calls)); +} + +async function main() { + await drainWaitsForInFlightCallToComplete(); + await drainTimesOutAndReportsIncomplete(); + await drainWithNoOpenSessionsReturnsImmediately(); + await samplingForwardRefusesOnceCompletedCallCapReached(); + await samplingForwardStillWorksBelowTheCap(); + await startupWatchdogTimesOutOnHungDownstreamConnect(); + + const passed = results.filter((r) => r.status === "PASS").length; + const failed = results.filter((r) => r.status === "FAIL").length; + console.log(`SUMMARY passed=${passed} failed=${failed} skipped=0`); + process.exit(failures ? 1 : 0); +} + +main().catch((error) => { + console.error("FATAL:", error.stack || error.message); + process.exit(1); +}); diff --git a/tests/gateway/mode-gate/run-tests.js b/tests/gateway/mode-gate/run-tests.js new file mode 100644 index 00000000..0d5e8dcb --- /dev/null +++ b/tests/gateway/mode-gate/run-tests.js @@ -0,0 +1,148 @@ +#!/usr/bin/env node +"use strict"; + +/* Regression suite for scripts/gateway/mode-gate.js (SG-FR-1/SG-FR-2). Exercises every + * fail-closed condition in the mode-selection contract TRD's SS7 table, plus SS8 test + * plan item 15 (attach configured, standalone binary started anyway -> dormant, not an + * error). See mode-gate.js's own header for why this validates against a PRIVATE, + * transcribed copy of the mode-selection contract's schema rather than a real + * schemas/mode-selection-contract.schema.json file (that file does not exist in this + * repo as of this build -- a disclosed gap, not routed around). */ + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const crypto = require("crypto"); + +const ROOT = path.resolve(__dirname, "../../.."); +const modeGate = require(path.join(ROOT, "scripts", "gateway", "mode-gate.js")); +const { writeConfirmedMode } = require("../_fixtures/mode-file.js"); + +let failures = 0; +const results = []; + +function record(name, status, reason) { + console.log(status === "PASS" ? `PASS ${name}` : `FAIL ${name}+${reason || "unknown"}`); + results.push({ name, status, reason: reason || "" }); + if (status === "FAIL") failures++; +} +function check(name, cond, reason) { + record(name, cond ? "PASS" : "FAIL", reason); +} + +function freshRoot(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), `gs-gateway-mode-${prefix}-`)); +} + +function expectCode(fn, expectedCode, name) { + let threw = null; + try { fn(); } catch (error) { threw = error; } + check(name, threw && threw.code === expectedCode, `expected ${expectedCode}, got ${threw ? threw.code : "no error"}`); +} + +/* SS7 condition 1: file missing. */ +function missingFile() { + const root = freshRoot("missing"); + expectCode(() => modeGate.readGatewayMode(root, { expectedMode: "standalone" }), "GATEWAY_MODE_NOT_DECLARED", "missing-mode-file-refused"); +} + +/* SS7 condition 2: unreadable (not ENOENT -- e.g. a directory where a file is expected). */ +function unreadableFile() { + const root = freshRoot("unreadable"); + fs.mkdirSync(path.join(root, ".graphsmith"), { recursive: true }); + fs.mkdirSync(path.join(root, ".graphsmith", "gateway-mode.json")); // a directory, not a file + expectCode(() => modeGate.readGatewayMode(root, { expectedMode: "standalone" }), "GATEWAY_MODE_UNREADABLE", "unreadable-mode-file-refused"); +} + +/* SS7 condition 3: not valid JSON. */ +function malformedJson() { + const root = freshRoot("malformed"); + fs.mkdirSync(path.join(root, ".graphsmith"), { recursive: true }); + fs.writeFileSync(path.join(root, ".graphsmith", "gateway-mode.json"), "{not json"); + expectCode(() => modeGate.readGatewayMode(root, { expectedMode: "standalone" }), "GATEWAY_MODE_MALFORMED", "malformed-json-refused"); +} + +/* SS7 condition 3 (schema-invalid variant): bad enum value for mode. */ +function badEnum() { + const root = freshRoot("bad-enum"); + fs.mkdirSync(path.join(root, ".graphsmith"), { recursive: true }); + fs.writeFileSync(path.join(root, ".graphsmith", "gateway-mode.json"), JSON.stringify({ schema_version: "1.0", mode: "sideways", confirmation: {} })); + expectCode(() => modeGate.readGatewayMode(root, { expectedMode: "standalone" }), "GATEWAY_MODE_INVALID", "bad-mode-enum-refused"); +} + +/* SS7 condition 4: confirmation block missing from an otherwise schema-valid record. */ +function missingConfirmation() { + const root = freshRoot("no-confirm"); + fs.mkdirSync(path.join(root, ".graphsmith"), { recursive: true }); + fs.writeFileSync(path.join(root, ".graphsmith", "gateway-mode.json"), JSON.stringify({ schema_version: "1.0", mode: "standalone" })); + expectCode(() => modeGate.readGatewayMode(root, { expectedMode: "standalone" }), "GATEWAY_MODE_NOT_CONFIRMED", "missing-confirmation-refused"); +} + +/* SS7 condition 5: confirmation_token present but does not recompute (tampered). */ +function tamperedToken() { + const root = freshRoot("tampered"); + writeConfirmedMode(root, "standalone", { badToken: "f".repeat(64) }); + expectCode(() => modeGate.readGatewayMode(root, { expectedMode: "standalone" }), "GATEWAY_MODE_CONFIRMATION_MISMATCH", "tampered-token-refused"); +} + +/* SS7 condition 6: secret file missing while confirmation_token is present. */ +function keyMissing() { + const root = freshRoot("key-missing"); + writeConfirmedMode(root, "standalone"); + fs.unlinkSync(path.join(root, ".graphsmith", "state", "gateway-mode.key")); + expectCode(() => modeGate.readGatewayMode(root, { expectedMode: "standalone" }), "GATEWAY_MODE_KEY_MISSING", "missing-key-file-refused"); +} + +/* SS7 condition 7 / SS8 test 15: valid + confirmed, but the OTHER binary's mode. This + * is the "dormant, not an error" case at the gateway.js layer -- here we confirm + * mode-gate.js itself reports it as a distinct, catchable code. */ +function wrongBinary() { + const root = freshRoot("wrong-binary"); + writeConfirmedMode(root, "attach"); + expectCode(() => modeGate.readGatewayMode(root, { expectedMode: "standalone" }), "GATEWAY_MODE_WRONG_BINARY", "attach-declared-refuses-standalone-binary"); +} + +/* Happy path: valid, confirmed, matching binary -> returns the record, does not throw. */ +function happyPath() { + const root = freshRoot("happy"); + const written = writeConfirmedMode(root, "standalone", { confirmedBy: "paul@example.com" }); + let result = null; + let threw = null; + try { result = modeGate.readGatewayMode(root, { expectedMode: "standalone" }); } catch (error) { threw = error; } + check("happy-path-does-not-throw", threw === null, threw && threw.message); + check("happy-path-returns-matching-record", result && result.mode === "standalone" && result.confirmation.confirmed_by === "paul@example.com", "record mismatch"); + void written; +} + +/* A confirmation computed under a DIFFERENT secret must fail, even with a + * well-formed 64-hex token (guards against "any hex64 string passes"). */ +function foreignSecretRejected() { + const root = freshRoot("foreign-secret"); + const record = writeConfirmedMode(root, "standalone"); + const wrongSecret = crypto.randomBytes(32); + const forgedToken = modeGate.computeConfirmationToken(wrongSecret, record); + const tampered = JSON.parse(fs.readFileSync(path.join(root, ".graphsmith", "gateway-mode.json"), "utf8")); + tampered.confirmation.confirmation_token = forgedToken; + fs.writeFileSync(path.join(root, ".graphsmith", "gateway-mode.json"), JSON.stringify(tampered)); + expectCode(() => modeGate.readGatewayMode(root, { expectedMode: "standalone" }), "GATEWAY_MODE_CONFIRMATION_MISMATCH", "foreign-secret-token-rejected"); +} + +function main() { + missingFile(); + unreadableFile(); + malformedJson(); + badEnum(); + missingConfirmation(); + tamperedToken(); + keyMissing(); + wrongBinary(); + happyPath(); + foreignSecretRejected(); + + const passed = results.filter((r) => r.status === "PASS").length; + const failed = results.filter((r) => r.status === "FAIL").length; + console.log(`SUMMARY passed=${passed} failed=${failed} skipped=0`); + process.exit(failures ? 1 : 0); +} + +main(); diff --git a/tests/gateway/proxy/run-tests.js b/tests/gateway/proxy/run-tests.js new file mode 100644 index 00000000..28a523e1 --- /dev/null +++ b/tests/gateway/proxy/run-tests.js @@ -0,0 +1,763 @@ +#!/usr/bin/env node +"use strict"; + +/* Regression suite for scripts/gateway/proxy.js (SS3.3/SS6 dispatch). Uses FAKE + * downstream connections (plain objects implementing .call()) rather than real child + * processes -- the transport itself is scripts/gateway/downstream.js's job and is + * covered by tests/gateway/e2e/run-tests.js against a real fixture process; this suite + * isolates the dispatch/correlation/routing logic. Covers SS8 test plan items: + * 2 multiple downstream servers -> tools correctly attributed, a call to server B's + * tool is routed to B, not A. + * 3 multiple concurrent agents -> independent sessions, no cross-contamination. + * 9 downstream unreachable at startup -> refuse to start (connectAllDownstreams). + * 10 downstream disconnect with an in-flight call -> recorded as error w/ marker. + * 14 malformed/hostile downstream response -> fails closed on that call, does not + * crash the gateway process. + */ + +const crypto = require("crypto"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const ROOT = path.resolve(__dirname, "../../.."); +const { GatewayProxy, isMalformedToolCallResult } = require(path.join(ROOT, "scripts", "gateway", "proxy.js")); +const chain = require(path.join(ROOT, "scripts", "gateway", "chain.js")); + +let failures = 0; +const results = []; +function record(name, status, reason) { + console.log(status === "PASS" ? `PASS ${name}` : `FAIL ${name}+${reason || "unknown"}`); + results.push({ name, status, reason: reason || "" }); + if (status === "FAIL") failures++; +} +function check(name, cond, reason) { + record(name, cond ? "PASS" : "FAIL", reason); +} +async function checkAsync(name, fn) { + try { + const cond = await fn(); + check(name, cond); + } catch (error) { + check(name, false, error.message); + } +} + +/* Codex PR #29 review round 8 "wait for notifications/initialized before admitting + * tools": GatewayProxy now only admits tools/list and tools/call after this + * notification has actually been received, not merely after "initialize" completes -- + * every existing test below that exercises tools/list or tools/call must send it too. */ +async function sendInitializedNotification(proxy, connectionId) { + await proxy.handleMessage(connectionId, { jsonrpc: "2.0", method: "notifications/initialized" }); +} + +function freshDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), `gs-gateway-proxy-${prefix}-`)); +} +function makeKeys() { + const kp = crypto.generateKeyPairSync("ed25519"); + return { privateKey: kp.privateKey, signer: "test-key", algo: "ed25519" }; +} + +/** A fake downstream connection: `impl(method, params) -> result` (may throw/reject). */ +function fakeConnection(impl) { + let closed = false; + return { + transport: "fake", + call: async (method, params) => { + if (closed) throw Object.assign(new Error("closed"), { code: "GATEWAY_DOWNSTREAM_DISCONNECTED" }); + return impl(method, params); + }, + close: () => { closed = true; }, + isClosed: () => closed, + whenClosed: () => new Promise(() => {}), // never resolves unless the test wants it to + }; +} + +function makeProxy(dir, connections, mergedTools, toolOwners, extra) { + return new GatewayProxy({ + connections, + mergedTools, + toolOwners, + serverInfos: {}, + keys: makeKeys(), + stateDir: dir, + ...extra, + }); +} + +async function multipleDownstreamAttribution() { + const dir = freshDir("attribution"); + const connA = fakeConnection(async (method, params) => ({ from: "A", method, params })); + const connB = fakeConnection(async (method, params) => ({ from: "B", method, params })); + const connections = new Map([["A", connA], ["B", connB]]); + const mergedTools = [{ name: "toolA", server: "A", schema: {} }, { name: "toolB", server: "B", schema: {} }]; + const toolOwners = new Map([["toolA", "A"], ["toolB", "B"]]); + const proxy = makeProxy(dir, connections, mergedTools, toolOwners); + + proxy.openConnection("conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); + await sendInitializedNotification(proxy, "conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); + const respA = await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "toolB", arguments: {} } }); + check("call-to-toolB-routed-to-server-B-not-A", respA.result.from === "B", JSON.stringify(respA)); + await proxy.closeConnection("conn-1", "test cleanup"); +} + +async function unknownToolRejected() { + const dir = freshDir("unknown-tool"); + const conn = fakeConnection(async () => ({})); + const proxy = makeProxy(dir, new Map([["A", conn]]), [], new Map()); + proxy.openConnection("conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 0, method: "initialize", params: {} }); + await sendInitializedNotification(proxy, "conn-1"); + const resp = await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "nonexistent", arguments: {} } }); + check("unknown-tool-returns-jsonrpc-error", resp.error && /Unknown tool/.test(resp.error.message), JSON.stringify(resp)); + await proxy.closeConnection("conn-1", "test cleanup"); +} + +async function concurrentAgentsIndependentSessions() { + const dir = freshDir("concurrent"); + const conn = fakeConnection(async (method, params) => ({ echoed: params })); + const mergedTools = [{ name: "echo", server: "srv", schema: {} }]; + const toolOwners = new Map([["echo", "srv"]]); + const proxy = makeProxy(dir, new Map([["srv", conn]]), mergedTools, toolOwners); + + proxy.openConnection("agent-1"); + proxy.openConnection("agent-2"); + /* gsa-mcp-shim.js's bundle_id hashes {init, grantedTools, n} -- not call CONTENT -- + * so two sessions need distinct `initialize` clientInfo to produce distinct bundle_ids + * (an existing, unchanged property of the shim being designed around here, not a bug + * in this test). */ + await proxy.handleMessage("agent-1", { jsonrpc: "2.0", id: 0, method: "initialize", params: { clientInfo: { name: "agent-one", version: "1" } } }); + await proxy.handleMessage("agent-2", { jsonrpc: "2.0", id: 0, method: "initialize", params: { clientInfo: { name: "agent-two", version: "1" } } }); + await sendInitializedNotification(proxy, "agent-1"); + await sendInitializedNotification(proxy, "agent-2"); + await proxy.handleMessage("agent-1", { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "echo", arguments: { who: "one" } } }); + await proxy.handleMessage("agent-2", { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "echo", arguments: { who: "two" } } }); + + check("two-independent-sessions-open", proxy.openSessionCount() === 2, String(proxy.openSessionCount())); + const s1 = proxy.sessions.get("agent-1"); + const s2 = proxy.sessions.get("agent-2"); + check("agent-1-session-has-only-its-own-call", s1.calls.length === 1 && s1.calls[0].arguments.who === "one", JSON.stringify(s1.calls)); + check("agent-2-session-has-only-its-own-call", s2.calls.length === 1 && s2.calls[0].arguments.who === "two", JSON.stringify(s2.calls)); + check("no-cross-contamination-between-sessions", s1.calls.every((c) => c.arguments.who !== "two") && s2.calls.every((c) => c.arguments.who !== "one"), "cross-contamination detected"); + + await proxy.closeConnection("agent-1", "test cleanup"); + await proxy.closeConnection("agent-2", "test cleanup"); +} + +async function downstreamDisconnectMarksErrorNotCrash() { + const dir = freshDir("disconnect"); + let rejectCall; + const conn = { + transport: "fake", + call: () => new Promise((resolve, reject) => { rejectCall = reject; }), + close: () => {}, + isClosed: () => false, + whenClosed: () => Promise.resolve(), + }; + const mergedTools = [{ name: "hangs", server: "srv", schema: {} }]; + const toolOwners = new Map([["hangs", "srv"]]); + const proxy = makeProxy(dir, new Map([["srv", conn]]), mergedTools, toolOwners); + proxy.openConnection("conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 0, method: "initialize", params: {} }); + await sendInitializedNotification(proxy, "conn-1"); + const callPromise = proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "hangs", arguments: {} } }); + // Simulate the downstream dying while the call is in flight. + rejectCall(Object.assign(new Error("downstream process exited"), { code: "GATEWAY_DOWNSTREAM_DISCONNECTED" })); + const resp = await callPromise; + check("in-flight-call-surfaces-as-jsonrpc-error-not-a-crash", resp.error && /downstream process exited/.test(resp.error.message), JSON.stringify(resp)); + const s = proxy.sessions.get("conn-1"); + check("in-flight-call-recorded-with-isError", s.calls.length === 1 && s.calls[0].isError === true, JSON.stringify(s.calls)); + await proxy.closeConnection("conn-1", "test cleanup"); +} + +async function malformedDownstreamResponseFailsClosedPerCall() { + const dir = freshDir("malformed"); + const conn = fakeConnection(async () => { throw new Error("malformed JSON from downstream"); }); + const goodConn = fakeConnection(async () => ({ ok: true })); + const mergedTools = [{ name: "bad", server: "bad-srv", schema: {} }, { name: "good", server: "good-srv", schema: {} }]; + const toolOwners = new Map([["bad", "bad-srv"], ["good", "good-srv"]]); + const proxy = makeProxy(dir, new Map([["bad-srv", conn], ["good-srv", goodConn]]), mergedTools, toolOwners); + proxy.openConnection("conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 0, method: "initialize", params: {} }); + await sendInitializedNotification(proxy, "conn-1"); + const badResp = await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "bad", arguments: {} } }); + check("malformed-response-fails-closed-as-jsonrpc-error", badResp.error !== undefined, JSON.stringify(badResp)); + const goodResp = await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "good", arguments: {} } }); + check("proxy-process-survives-and-serves-next-call", goodResp.result && goodResp.result.ok === true, JSON.stringify(goodResp)); + await proxy.closeConnection("conn-1", "test cleanup"); +} + +async function connectionCloseWithPendingCallsMarksDisconnected() { + const dir = freshDir("close-pending"); + const conn = { transport: "fake", call: () => new Promise(() => {}), close: () => {}, isClosed: () => false, whenClosed: () => Promise.resolve() }; + const mergedTools = [{ name: "neverresponds", server: "srv", schema: {} }]; + const toolOwners = new Map([["neverresponds", "srv"]]); + const logLines = []; + const proxy = makeProxy(dir, new Map([["srv", conn]]), mergedTools, toolOwners, { log: (line) => logLines.push(line) }); + proxy.openConnection("conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 0, method: "initialize", params: {} }); + await sendInitializedNotification(proxy, "conn-1"); + proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "neverresponds", arguments: {} } }); // fire and forget, never resolves + const entry = await proxy.closeConnection("conn-1", "agent hung up mid-call"); + check("closing-connection-with-pending-call-still-produces-a-chain-entry", entry !== null, "no entry appended"); + const bundle = JSON.parse(fs.readFileSync(chain.bundlePath(dir, entry.bundle_id), "utf8")); + const trace = bundle.contents["execution_trace.jsonl"]; + check("finalized-bundle-trace-shows-the-pending-call-as-an-error", /"is_error":true/.test(trace), trace); + + /* Codex PR #29 review round 4 "log calls finalized as disconnected": a call finalized + * via closeConnection's own pending-call cleanup previously got no matching + * "gateway_call_completed" operational log line at all, unlike every other way a call + * can complete. */ + const disconnectLog = logLines.map((l) => { try { return JSON.parse(l); } catch (error) { return null; } }).find((l) => l && l.event === "gateway_call_completed" && l.status === "disconnected"); + check( + "close-connection-with-pending-call-emits-completion-log", + Boolean(disconnectLog && disconnectLog.connection_id === "conn-1" && disconnectLog.tool === "neverresponds" && typeof disconnectLog.duration_ms === "number"), + JSON.stringify(logLines) + ); +} + +/** Codex PR #29 review round 4 "log calls finalized as disconnected": a downstream + * disconnect mid-session (handleDownstreamDisconnect, distinct from closeConnection + * above) marks that server's pending calls disconnected too, and must emit the same + * structured completion log for each -- otherwise those steps have no run ID, status, or + * duration anywhere in the operational log despite being fully recorded in the trace. */ +async function downstreamDisconnectEmitsCompletionLog() { + const dir = freshDir("disconnect-log"); + const conn = { transport: "fake", call: () => new Promise(() => {}), close: () => {}, isClosed: () => false, whenClosed: () => new Promise(() => {}) }; + const mergedTools = [{ name: "hangs", server: "srv", schema: {} }]; + const toolOwners = new Map([["hangs", "srv"]]); + const logLines = []; + const proxy = makeProxy(dir, new Map([["srv", conn]]), mergedTools, toolOwners, { log: (line) => logLines.push(line) }); + proxy.openConnection("conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 0, method: "initialize", params: {} }); + await sendInitializedNotification(proxy, "conn-1"); + proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "hangs", arguments: {} } }); // fire and forget, never resolves + proxy.handleDownstreamDisconnect("srv"); + const s = proxy.sessions.get("conn-1"); + check("downstream-disconnect-marks-pending-call-disconnected", s.calls.length === 1 && s.calls[0].disconnected === true, JSON.stringify(s.calls)); + const disconnectLog = logLines.map((l) => { try { return JSON.parse(l); } catch (error) { return null; } }).find((l) => l && l.event === "gateway_call_completed" && l.status === "disconnected"); + check( + "downstream-disconnect-emits-completion-log", + Boolean(disconnectLog && disconnectLog.connection_id === "conn-1" && disconnectLog.tool === "hangs" && disconnectLog.server === "srv" && typeof disconnectLog.duration_ms === "number"), + JSON.stringify(logLines) + ); + await proxy.closeConnection("conn-1", "test cleanup"); +} + +/* Codex PR #29 review "bound completed call history retained by each session": a + * session that never trips MAX_PENDING_CALLS_PER_SESSION (calls issued one at a time, + * never concurrently) could previously grow session.calls without any bound at all. + * Drives the session's own recorded-call count up to the cap directly (issuing that many + * real calls would make this test absurdly slow) rather than through 100000 real round + * trips, then asserts the NEXT call is refused exactly the way the pending-call cap + * already refuses admission once its own limit is hit. */ +async function completedCallHistoryCapped() { + const { MAX_COMPLETED_CALLS_PER_SESSION } = require(path.join(ROOT, "scripts", "gateway", "proxy.js")); + const dir = freshDir("completed-call-cap"); + const conn = fakeConnection(async () => ({ ok: true })); + const mergedTools = [{ name: "tool", server: "srv", schema: {} }]; + const toolOwners = new Map([["tool", "srv"]]); + const proxy = makeProxy(dir, new Map([["srv", conn]]), mergedTools, toolOwners); + proxy.openConnection("conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 0, method: "initialize", params: {} }); + await sendInitializedNotification(proxy, "conn-1"); + const s = proxy.sessions.get("conn-1"); + s.calls.length = MAX_COMPLETED_CALLS_PER_SESSION; // cheap stand-in for MAX_COMPLETED_CALLS_PER_SESSION genuinely-completed calls + const resp = await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "tool", arguments: {} } }); + check( + "completed-call-history-cap-refuses-further-admission", + Boolean(resp.error && resp.error.code === -32000 && /already completed/.test(resp.error.message)), + JSON.stringify(resp) + ); + check("completed-call-history-cap-does-not-grow-past-the-cap", s.calls.length === MAX_COMPLETED_CALLS_PER_SESSION, String(s.calls.length)); + await proxy.closeConnection("conn-1", "test cleanup"); +} + +async function stopAcceptingNewSessionsRefusesNewButNotExisting() { + const dir = freshDir("stop-accepting"); + const conn = fakeConnection(async () => ({ ok: true })); + const proxy = makeProxy(dir, new Map([["srv", conn]]), [], new Map()); + proxy.openConnection("conn-1"); + proxy.stopAcceptingNewSessions(); + let threw = null; + try { proxy.openConnection("conn-2"); } catch (error) { threw = error; } + check("stopAcceptingNewSessions-refuses-new-connection", threw && threw.code === "GATEWAY_NOT_ACCEPTING", threw && threw.code); + const entry = await proxy.closeConnection("conn-1", "already-open session still finalizes"); + check("already-open-session-still-finalizes-after-stop", entry !== null, "existing session was not finalized"); +} + +/* Board decision 2026-09-04, PR #29 review "honor MCP tool-level error results": a + * `tools/call` result carrying `isError: true` on the RESULT itself (not a thrown + * transport/RPC error) must be recorded as an error in the session, but the JSON-RPC + * response sent back to the agent stays a normal `result` (MCP tool-level errors are not + * protocol errors). */ +async function toolLevelErrorRecordedButNotProtocolError() { + const dir = freshDir("tool-level-error"); + const conn = fakeConnection(async () => ({ content: [{ type: "text", text: "boom" }], isError: true })); + const mergedTools = [{ name: "flaky", server: "srv", schema: {} }]; + const toolOwners = new Map([["flaky", "srv"]]); + const proxy = makeProxy(dir, new Map([["srv", conn]]), mergedTools, toolOwners); + proxy.openConnection("conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 0, method: "initialize", params: {} }); + await sendInitializedNotification(proxy, "conn-1"); + const resp = await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "flaky", arguments: {} } }); + check("tool-level-isError-still-returns-a-normal-result-not-a-protocol-error", resp.result && resp.result.isError === true && resp.error === undefined, JSON.stringify(resp)); + const s = proxy.sessions.get("conn-1"); + check("tool-level-isError-recorded-as-error-in-session", s.calls.length === 1 && s.calls[0].isError === true, JSON.stringify(s.calls)); + await proxy.closeConnection("conn-1", "test cleanup"); +} + +/* Board decision 2026-09-04, PR #29 review "preserve downstream JSON-RPC error + * envelopes": a downstream's own structured JSON-RPC error (code/data) must reach the + * agent unchanged, not be flattened into a generic -32000. */ +async function preservesDownstreamJsonRpcErrorEnvelope() { + const dir = freshDir("rpc-error"); + const conn = { + transport: "fake", + call: async () => { throw Object.assign(new Error("Invalid arguments"), { code: "GATEWAY_DOWNSTREAM_RPC_ERROR", rpcError: { code: -32602, message: "Invalid arguments", data: { field: "arguments.x" } } }); }, + close: () => {}, + isClosed: () => false, + whenClosed: () => new Promise(() => {}), + }; + const mergedTools = [{ name: "picky", server: "srv", schema: {} }]; + const toolOwners = new Map([["picky", "srv"]]); + const proxy = makeProxy(dir, new Map([["srv", conn]]), mergedTools, toolOwners); + proxy.openConnection("conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 0, method: "initialize", params: {} }); + await sendInitializedNotification(proxy, "conn-1"); + const resp = await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "picky", arguments: {} } }); + check( + "downstream-rpc-error-code-and-data-preserved-not-flattened-to--32000", + resp.error && resp.error.code === -32602 && resp.error.data && resp.error.data.field === "arguments.x", + JSON.stringify(resp) + ); + await proxy.closeConnection("conn-1", "test cleanup"); +} + +/* Board decision 2026-09-04, PR #29 review "enforce the agent initialization + * lifecycle". */ +async function initializationLifecycleEnforced() { + const dir = freshDir("init-lifecycle"); + const conn = fakeConnection(async () => ({ ok: true })); + const mergedTools = [{ name: "echo", server: "srv", schema: {} }]; + const toolOwners = new Map([["echo", "srv"]]); + const proxy = makeProxy(dir, new Map([["srv", conn]]), mergedTools, toolOwners); + proxy.openConnection("conn-1"); + + const preInitList = await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }); + check("tools-list-before-initialize-rejected", preInitList.error !== undefined, JSON.stringify(preInitList)); + const preInitCall = await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "echo", arguments: {} } }); + check("tools-call-before-initialize-rejected", preInitCall.error !== undefined, JSON.stringify(preInitCall)); + + const initResp = await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 3, method: "initialize", params: {} }); + check("first-initialize-succeeds", initResp.result !== undefined, JSON.stringify(initResp)); + + /* Codex PR #29 review round 8 "wait for notifications/initialized before admitting + * tools": the "initialize" response alone must NOT be enough to unlock tools/list or + * tools/call -- a conforming client's own subsequent "notifications/initialized" is + * required first, matching the same rejection shape as a pre-initialize call. */ + const preNotifCall = await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 6, method: "tools/call", params: { name: "echo", arguments: {} } }); + check("tools-call-after-initialize-but-before-notifications-initialized-rejected", preNotifCall.error !== undefined, JSON.stringify(preNotifCall)); + const preNotifList = await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 7, method: "tools/list", params: {} }); + check("tools-list-after-initialize-but-before-notifications-initialized-rejected", preNotifList.error !== undefined, JSON.stringify(preNotifList)); + + await sendInitializedNotification(proxy, "conn-1"); + + const postInitCall = await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "echo", arguments: {} } }); + check("tools-call-after-notifications-initialized-succeeds", postInitCall.result && postInitCall.result.ok === true, JSON.stringify(postInitCall)); + + const repeatInit = await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 5, method: "initialize", params: {} }); + check("repeated-initialize-rejected", repeatInit.error !== undefined, JSON.stringify(repeatInit)); + + await proxy.closeConnection("conn-1", "test cleanup"); +} + +/* Codex PR #29 review round 8 "wait for notifications/initialized before admitting + * tools": an initialize sent AS A NOTIFICATION (no "id") must not unlock tools either -- + * the client never received the selected protocol/capabilities in that case, so admitting + * tools afterward would let it invoke a contract it was never shown. */ +async function noIdInitializeDoesNotUnlockToolsWithoutNotification() { + const dir = freshDir("init-notify-noid"); + const conn = fakeConnection(async () => ({ ok: true })); + const mergedTools = [{ name: "echo", server: "srv", schema: {} }]; + const toolOwners = new Map([["echo", "srv"]]); + const proxy = makeProxy(dir, new Map([["srv", conn]]), mergedTools, toolOwners); + proxy.openConnection("conn-1"); + + const initResp = await proxy.handleMessage("conn-1", { jsonrpc: "2.0", method: "initialize", params: {} }); + check("id-less-initialize-notification-produces-no-response", initResp === null, JSON.stringify(initResp)); + + const callBeforeNotification = await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "echo", arguments: {} } }); + check("tools-call-after-id-less-initialize-but-before-notifications-initialized-rejected", callBeforeNotification.error !== undefined, JSON.stringify(callBeforeNotification)); + + await sendInitializedNotification(proxy, "conn-1"); + const callAfterNotification = await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "echo", arguments: {} } }); + check("tools-call-after-notifications-initialized-following-id-less-initialize-succeeds", callAfterNotification.result && callAfterNotification.result.ok === true, JSON.stringify(callAfterNotification)); + + await proxy.closeConnection("conn-1", "test cleanup"); +} + +/* Board decision 2026-09-04, PR #29 review "emit the required structured log for each + * call": one structured line per completed call, naming the connection, step, tool, + * status, and duration -- not only the much-later (or never, on a crash) session-finalize + * log. */ +async function structuredLogEmittedPerCompletedCall() { + const dir = freshDir("structured-log"); + const conn = fakeConnection(async () => ({ ok: true })); + const mergedTools = [{ name: "echo", server: "srv", schema: {} }]; + const toolOwners = new Map([["echo", "srv"]]); + const logLines = []; + const proxy = makeProxy(dir, new Map([["srv", conn]]), mergedTools, toolOwners, { log: (line) => logLines.push(line) }); + proxy.openConnection("conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 0, method: "initialize", params: {} }); + await sendInitializedNotification(proxy, "conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "echo", arguments: {} } }); + const callLog = logLines.map((l) => { try { return JSON.parse(l); } catch (error) { return null; } }).find((l) => l && l.event === "gateway_call_completed"); + check( + "structured-log-line-emitted-for-completed-call", + callLog && callLog.connection_id === "conn-1" && callLog.tool === "echo" && callLog.status === "ok" && typeof callLog.duration_ms === "number", + JSON.stringify(logLines) + ); + await proxy.closeConnection("conn-1", "test cleanup"); +} + +/* Codex PR #29 review "forward the full cached tool descriptor": tools/list previously + * projected each tool down to {name, description, inputSchema}, discarding whatever else + * the downstream actually advertised (outputSchema, annotations, title, ...) even though + * downstream.js already preserves it on the cached descriptor. Only the two + * gateway-private fields (`server`, and the internal `schema` alias of inputSchema) + * should ever be stripped. */ +async function toolsListForwardsFullDescriptor() { + const dir = freshDir("full-descriptor"); + const conn = fakeConnection(async () => ({ ok: true })); + const mergedTools = [ + { + name: "echo", + description: "echoes its arguments", + inputSchema: { type: "object" }, + outputSchema: { type: "object", properties: { echoed: {} } }, + annotations: { title: "Echo", readOnlyHint: true }, + server: "srv", // gateway-private ownership field: must NOT reach the agent + schema: { type: "object" }, // gateway-private internal alias: must NOT reach the agent + }, + ]; + const toolOwners = new Map([["echo", "srv"]]); + const proxy = makeProxy(dir, new Map([["srv", conn]]), mergedTools, toolOwners); + proxy.openConnection("conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 0, method: "initialize", params: {} }); + await sendInitializedNotification(proxy, "conn-1"); + const listResp = await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }); + const tool = listResp.result && listResp.result.tools && listResp.result.tools[0]; + check( + "tools-list-forwards-outputSchema-and-annotations", + tool && tool.name === "echo" && tool.description === "echoes its arguments" && + JSON.stringify(tool.outputSchema) === JSON.stringify(mergedTools[0].outputSchema) && + JSON.stringify(tool.annotations) === JSON.stringify(mergedTools[0].annotations) && + JSON.stringify(tool.inputSchema) === JSON.stringify(mergedTools[0].inputSchema), + JSON.stringify(tool) + ); + check( + "tools-list-strips-gateway-private-server-and-schema-fields", + tool && tool.server === undefined && tool.schema === undefined, + JSON.stringify(tool) + ); + await proxy.closeConnection("conn-1", "test cleanup"); +} + +/* Codex PR #29 review "handle JSON-RPC null IDs before recording calls": JSON-RPC 2.0 + * permits an explicit `id: null` on a request (distinct from a notification, which omits + * "id" entirely). Using it directly as the internal correlation key previously reached + * session.recordCallStart's Map key, which throws INVALID_ARGUMENT on null -- turning a + * legal-if-unusual request into an escaped internal error instead of a normal response. */ +async function nullJsonRpcIdDoesNotCrash() { + const dir = freshDir("null-id"); + const conn = fakeConnection(async () => ({ ok: true })); + const mergedTools = [{ name: "echo", server: "srv", schema: {} }]; + const toolOwners = new Map([["echo", "srv"]]); + const proxy = makeProxy(dir, new Map([["srv", conn]]), mergedTools, toolOwners); + proxy.openConnection("conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 0, method: "initialize", params: {} }); + await sendInitializedNotification(proxy, "conn-1"); + const resp = await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: null, method: "tools/call", params: { name: "echo", arguments: {} } }); + check( + "null-jsonrpc-id-tools-call-does-not-throw-and-echoes-null-id", + resp && resp.id === null && resp.result && resp.result.ok === true, + JSON.stringify(resp) + ); + await proxy.closeConnection("conn-1", "test cleanup"); +} + +/* Codex PR #29 review round 3 "retain sessions when persistence fails": closeConnection + * used to discard the fully-sealed bundle the moment chain.appendSession threw, leaving + * only a summary log line. Two connections given identical initialize params and an + * identical call count deterministically produce the same bundle_id (gsa-mcp-shim.js + * hashes only {init, grantedTools, n} -- see chain.js's own "same agent reconnecting ... + * reproduces it" comment), so the second one's real chain.appendSession call genuinely + * throws GATEWAY_BUNDLE_ID_COLLISION here rather than a synthetic/forced failure. */ +async function persistenceFailureQuarantinesSealedBundle() { + const dir = freshDir("quarantine"); + const conn = fakeConnection(async () => ({ ok: true })); + const mergedTools = [{ name: "echo", server: "srv", schema: {} }]; + const toolOwners = new Map([["echo", "srv"]]); + const sealFailures = []; + const proxy = makeProxy(dir, new Map([["srv", conn]]), mergedTools, toolOwners, { + onSealFailure: (s, error) => sealFailures.push(error), + }); + + proxy.openConnection("conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "initialize", params: { clientInfo: { name: "x", version: "1" } } }); + await sendInitializedNotification(proxy, "conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "echo", arguments: {} } }); + const entry1 = await proxy.closeConnection("conn-1", "test cleanup"); + check("quarantine-setup-first-append-succeeds", Boolean(entry1 && typeof entry1.bundle_id === "string"), JSON.stringify(entry1)); + + proxy.openConnection("conn-2"); + await proxy.handleMessage("conn-2", { jsonrpc: "2.0", id: 1, method: "initialize", params: { clientInfo: { name: "x", version: "1" } } }); + await sendInitializedNotification(proxy, "conn-2"); + await proxy.handleMessage("conn-2", { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "echo", arguments: {} } }); + const entry2 = await proxy.closeConnection("conn-2", "test cleanup"); + check("persistence-failure-returns-null", entry2 === null, JSON.stringify(entry2)); + check( + "persistence-failure-invokes-onSealFailure-with-collision-code", + sealFailures.length === 1 && sealFailures[0].code === "GATEWAY_BUNDLE_ID_COLLISION", + JSON.stringify(sealFailures.map((e) => e && e.code)) + ); + + const quarantinedTo = sealFailures[0] && sealFailures[0].quarantinedTo; + check("persistence-failure-quarantines-sealed-bundle", typeof quarantinedTo === "string" && fs.existsSync(quarantinedTo), String(quarantinedTo)); + if (typeof quarantinedTo === "string" && fs.existsSync(quarantinedTo)) { + const quarantined = JSON.parse(fs.readFileSync(quarantinedTo, "utf8")); + check( + "quarantined-file-preserves-the-sealed-bundle", + Boolean(quarantined.sealed && quarantined.sealed.bundle && quarantined.sealed.bundle.manifest), + JSON.stringify(Object.keys(quarantined)) + ); + } +} + +/* Cluster C: onClaimLost (gateway.js) only calls proxy.stopAcceptingNewSessions() -- + * an already-open session is deliberately left alone to finish and finalize (SS7). This + * covers the narrower fix: closeConnection must re-check isWriterClaimValid() + * synchronously immediately before chain.appendSession, and refuse to append (quarantine + * instead) if the claim is no longer valid at THAT moment -- not just at the moment + * claim-loss was first detected. */ +async function writerClaimRevalidatedImmediatelyBeforeAppend() { + const dir = freshDir("writer-claim-append"); + const conn = fakeConnection(async () => ({ ok: true })); + const mergedTools = [{ name: "echo", server: "srv", schema: {} }]; + const toolOwners = new Map([["echo", "srv"]]); + const sealFailures = []; + let claimValid = true; + const proxy = makeProxy(dir, new Map([["srv", conn]]), mergedTools, toolOwners, { + onSealFailure: (s, error) => sealFailures.push(error), + isWriterClaimValid: () => claimValid, + }); + + proxy.openConnection("conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "initialize", params: { clientInfo: { name: "x", version: "1" } } }); + await sendInitializedNotification(proxy, "conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "echo", arguments: {} } }); + // Simulate the claim being lost/stolen by a replacement writer AFTER this session + // opened (and even after it ran its call) but before it happens to finalize -- + // exactly the window onClaimLost's own "in-flight sessions are left alone" comment + // describes. + claimValid = false; + const entry = await proxy.closeConnection("conn-1", "test cleanup"); + check("append-refused-when-writer-claim-invalid-at-close-time", entry === null, JSON.stringify(entry)); + check( + "refused-append-invokes-onSealFailure-with-claim-lost-code", + sealFailures.length === 1 && sealFailures[0].code === "GATEWAY_WRITER_CLAIM_LOST_AT_APPEND", + JSON.stringify(sealFailures.map((e) => e && e.code)) + ); + const quarantinedTo = sealFailures[0] && sealFailures[0].quarantinedTo; + check("claim-lost-append-quarantines-sealed-bundle-for-recovery", typeof quarantinedTo === "string" && fs.existsSync(quarantinedTo), String(quarantinedTo)); + check("no-chain-entry-was-actually-written", chain.readHead(dir) === null, JSON.stringify(chain.readHead(dir))); + + // A session opened AFTER the claim is valid again finalizes normally -- this is a + // per-append check, not a permanent proxy-wide latch. + claimValid = true; + proxy.openConnection("conn-2"); + await proxy.handleMessage("conn-2", { jsonrpc: "2.0", id: 1, method: "initialize", params: { clientInfo: { name: "y", version: "1" } } }); + const entry2 = await proxy.closeConnection("conn-2", "test cleanup"); + check("append-succeeds-once-writer-claim-is-valid-again", Boolean(entry2 && typeof entry2.bundle_id === "string"), JSON.stringify(entry2)); +} + +/* Default behavior (no isWriterClaimValid passed, e.g. every other test in this suite, + * and any production caller that hasn't wired a writer-claim at all): appends must not + * be refused. Mirrors onSessionFinalized/onSealFailure's own default-no-op contract. */ +async function writerClaimCheckDefaultsToValidWhenNotProvided() { + const dir = freshDir("writer-claim-default"); + const conn = fakeConnection(async () => ({ ok: true })); + const mergedTools = [{ name: "echo", server: "srv", schema: {} }]; + const toolOwners = new Map([["echo", "srv"]]); + const proxy = makeProxy(dir, new Map([["srv", conn]]), mergedTools, toolOwners); // no isWriterClaimValid + proxy.openConnection("conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); + const entry = await proxy.closeConnection("conn-1", "test cleanup"); + check("append-not-refused-when-no-writer-claim-check-configured", Boolean(entry && typeof entry.bundle_id === "string"), JSON.stringify(entry)); +} + +/* PR #29 review thread 4000335147, frontier-panel review (4/4 independent models + * converged, two rounds of critique) "flag structurally malformed tools/call results + * distinctly from tool-level errors": pure predicate matrix, direct against + * isMalformedToolCallResult -- no proxy/session plumbing needed for these, they exercise + * the converged shape itself (a well-formed CallToolResult must have at least one of a + * `content` array, a `structuredContent` object, or an explicit `isError` boolean; + * extra/unknown fields never trip it). */ +function malformedResultPredicateMatrix() { + check("null-is-malformed", isMalformedToolCallResult(null) === true); + check("string-is-malformed", isMalformedToolCallResult("some string") === true); + check("number-is-malformed", isMalformedToolCallResult(42) === true); + check("array-is-malformed", isMalformedToolCallResult([]) === true); + check("empty-object-is-malformed", isMalformedToolCallResult({}) === true); + check("empty-content-array-is-not-malformed", isMalformedToolCallResult({ content: [] }) === false); + check("populated-content-array-is-not-malformed", isMalformedToolCallResult({ content: [{ type: "text", text: "ok" }] }) === false); + check( + "isError-true-with-content-is-not-malformed-both-flags-coexist", + isMalformedToolCallResult({ isError: true, content: [] }) === false + ); + check("structuredContent-object-is-not-malformed", isMalformedToolCallResult({ structuredContent: {} }) === false); + check( + "extra-unknown-fields-do-not-trigger-malformed", + isMalformedToolCallResult({ content: [{ type: "text", text: "ok" }], extra_field: 1 }) === false + ); + check("isError-boolean-alone-is-not-malformed", isMalformedToolCallResult({ isError: false }) === false); + check("predicate-does-not-throw-on-hostile-input", (() => { + const hostile = {}; + Object.defineProperty(hostile, "content", { get() { throw new Error("boom"); } }); + let threw = false; + let out; + try { out = isMalformedToolCallResult(hostile); } catch (error) { threw = true; } + return !threw && out === false; // fails open on the check itself, per spec + })()); +} + +/* Integration: malformedResult wired through handleMessage -> session.recordCallResult, + * additive-only and independent of isError/the JSON-RPC response. */ +async function malformedResultRecordedAndResponseUnchanged() { + const dir = freshDir("malformed-result"); + const conn = fakeConnection(async () => ({})); // no content/structuredContent/isError + const mergedTools = [{ name: "shapeless", server: "srv", schema: {} }]; + const toolOwners = new Map([["shapeless", "srv"]]); + const logLines = []; + const proxy = makeProxy(dir, new Map([["srv", conn]]), mergedTools, toolOwners, { log: (line) => logLines.push(line) }); + proxy.openConnection("conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 0, method: "initialize", params: {} }); + await sendInitializedNotification(proxy, "conn-1"); + const resp = await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "shapeless", arguments: {} } }); + check( + "malformed-result-json-rpc-response-and-isError-completely-unchanged", + resp.error === undefined && resp.result && typeof resp.result === "object" && Object.keys(resp.result).length === 0 && resp.result.isError === undefined, + JSON.stringify(resp) + ); + const s = proxy.sessions.get("conn-1"); + check( + "malformed-result-flagged-in-session-without-flipping-isError", + s.calls.length === 1 && s.calls[0].malformedResult === true && s.calls[0].isError === false, + JSON.stringify(s.calls) + ); + const callLog = logLines.map((l) => { try { return JSON.parse(l); } catch (error) { return null; } }).find((l) => l && l.event === "gateway_call_completed"); + check( + "malformed-result-log-line-carries-malformed-result-true", + Boolean(callLog && callLog.malformed_result === true && callLog.status === "ok"), + JSON.stringify(logLines) + ); + await proxy.closeConnection("conn-1", "test cleanup"); +} + +/* A well-formed call must be completely unaffected: no malformedResult flag, no + * malformed_result key on the log line, and -- the byte-parity requirement -- no + * malformed_result key anywhere in the signed execution_trace line either. */ +async function wellFormedCallLeavesNoMalformedResultTrace() { + const dir = freshDir("malformed-result-clean"); + const conn = fakeConnection(async () => ({ content: [{ type: "text", text: "ok" }] })); + const mergedTools = [{ name: "clean", server: "srv", schema: {} }]; + const toolOwners = new Map([["clean", "srv"]]); + const logLines = []; + const proxy = makeProxy(dir, new Map([["srv", conn]]), mergedTools, toolOwners, { log: (line) => logLines.push(line) }); + proxy.openConnection("conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 0, method: "initialize", params: {} }); + await sendInitializedNotification(proxy, "conn-1"); + await proxy.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "clean", arguments: {} } }); + const s = proxy.sessions.get("conn-1"); + check("well-formed-call-not-flagged-malformed", s.calls.length === 1 && s.calls[0].malformedResult === false, JSON.stringify(s.calls)); + const callLog = logLines.map((l) => { try { return JSON.parse(l); } catch (error) { return null; } }).find((l) => l && l.event === "gateway_call_completed"); + check("well-formed-call-log-line-has-no-malformed-result-key", Boolean(callLog) && !Object.prototype.hasOwnProperty.call(callLog, "malformed_result"), JSON.stringify(logLines)); + const entry = await proxy.closeConnection("conn-1", "test cleanup"); + const bundle = JSON.parse(fs.readFileSync(chain.bundlePath(dir, entry.bundle_id), "utf8")); + const trace = bundle.contents["execution_trace.jsonl"]; + check("well-formed-call-trace-line-byte-parity-no-malformed-result-key", !trace.includes("malformed_result"), trace); +} + +/* Scoped strictly to tools/call + !transportFailed: a sampling/createMessage (model_call) + * result and a transport-failed tools/call must never get malformedResult computed for + * them, regardless of how shapeless the underlying value is. */ +async function malformedResultNotComputedOutsideItsScope() { + const dir = freshDir("malformed-result-scope"); + const samplingConn = fakeConnection(async () => null); // maximally shapeless + const mergedToolsA = []; + const proxyA = makeProxy(dir, new Map([["srv", samplingConn]]), mergedToolsA, new Map()); + proxyA.openConnection("conn-1"); + await proxyA.handleMessage("conn-1", { jsonrpc: "2.0", id: 1, method: "sampling/createMessage", params: { server: "srv" } }); + const sA = proxyA.sessions.get("conn-1"); + check( + "malformed-result-never-computed-for-sampling-createMessage", + sA.calls.length === 1 && sA.calls[0].model_call === true && sA.calls[0].malformedResult === false, + JSON.stringify(sA.calls) + ); + await proxyA.closeConnection("conn-1", "test cleanup"); + + const dir2 = freshDir("malformed-result-transport-fail"); + const failingConn = fakeConnection(async () => { throw new Error("downstream exploded"); }); + const mergedTools = [{ name: "boom", server: "srv", schema: {} }]; + const toolOwners = new Map([["boom", "srv"]]); + const proxyB = makeProxy(dir2, new Map([["srv", failingConn]]), mergedTools, toolOwners); + proxyB.openConnection("conn-2"); + await proxyB.handleMessage("conn-2", { jsonrpc: "2.0", id: 0, method: "initialize", params: {} }); + await sendInitializedNotification(proxyB, "conn-2"); + const resp = await proxyB.handleMessage("conn-2", { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "boom", arguments: {} } }); + check("transport-failure-still-a-protocol-error", resp.error !== undefined, JSON.stringify(resp)); + const sB = proxyB.sessions.get("conn-2"); + check( + "malformed-result-never-computed-for-a-transport-failed-call", + sB.calls.length === 1 && sB.calls[0].isError === true && sB.calls[0].malformedResult === false, + JSON.stringify(sB.calls) + ); + await proxyB.closeConnection("conn-2", "test cleanup"); +} + +async function main() { + await multipleDownstreamAttribution(); + await unknownToolRejected(); + await concurrentAgentsIndependentSessions(); + await downstreamDisconnectMarksErrorNotCrash(); + await malformedDownstreamResponseFailsClosedPerCall(); + await connectionCloseWithPendingCallsMarksDisconnected(); + await downstreamDisconnectEmitsCompletionLog(); + await completedCallHistoryCapped(); + await stopAcceptingNewSessionsRefusesNewButNotExisting(); + await toolLevelErrorRecordedButNotProtocolError(); + malformedResultPredicateMatrix(); + await malformedResultRecordedAndResponseUnchanged(); + await wellFormedCallLeavesNoMalformedResultTrace(); + await malformedResultNotComputedOutsideItsScope(); + await preservesDownstreamJsonRpcErrorEnvelope(); + await initializationLifecycleEnforced(); + await noIdInitializeDoesNotUnlockToolsWithoutNotification(); + await structuredLogEmittedPerCompletedCall(); + await toolsListForwardsFullDescriptor(); + await nullJsonRpcIdDoesNotCrash(); + await persistenceFailureQuarantinesSealedBundle(); + await writerClaimRevalidatedImmediatelyBeforeAppend(); + await writerClaimCheckDefaultsToValidWhenNotProvided(); + + const passed = results.filter((r) => r.status === "PASS").length; + const failed = results.filter((r) => r.status === "FAIL").length; + console.log(`SUMMARY passed=${passed} failed=${failed} skipped=0`); + process.exit(failures ? 1 : 0); +} + +main(); diff --git a/tests/gateway/session/run-tests.js b/tests/gateway/session/run-tests.js new file mode 100644 index 00000000..dad7007d --- /dev/null +++ b/tests/gateway/session/run-tests.js @@ -0,0 +1,264 @@ +#!/usr/bin/env node +"use strict"; + +/* Regression suite for scripts/gateway/session.js (Standalone Gateway TRD SS3.3/SS3.4/ + * SS3.5). Covers SS8 test plan items: 2 (tool/server attribution), 4 (out-of-order + * correlation by JSON-RPC id), 10 (downstream disconnect marks pending as error, not + * silently dropped), 11 (unmatched response is an anomaly, does not crash). */ + +const crypto = require("crypto"); +const path = require("path"); + +const ROOT = path.resolve(__dirname, "../../.."); +const session = require(path.join(ROOT, "scripts", "gateway", "session.js")); + +let failures = 0; +const results = []; +function record(name, status, reason) { + console.log(status === "PASS" ? `PASS ${name}` : `FAIL ${name}+${reason || "unknown"}`); + results.push({ name, status, reason: reason || "" }); + if (status === "FAIL") failures++; +} +function check(name, cond, reason) { + record(name, cond ? "PASS" : "FAIL", reason); +} + +function makeKeys() { + const kp = crypto.generateKeyPairSync("ed25519"); + return { privateKey: kp.privateKey, signer: "test-key", algo: "ed25519" }; +} + +function createSessionRejectsBadConnectionId() { + let threw = null; + try { session.createSession(""); } catch (error) { threw = error; } + check("createSession-rejects-empty-connectionId", threw && threw.code === "INVALID_ARGUMENT", threw && threw.code); +} + +function toolServerAttribution() { + const s = session.createSession("conn-1"); + session.recordToolsList(s, [ + { name: "read", server: "fs", schema: {} }, + { name: "fetch", server: "web", schema: {} }, + ]); + session.recordCallStart(s, 1, { tool: "read", server: "fs", arguments: { path: "a" }, ts: 1 }); + session.recordCallResult(s, 1, { result: { ok: true }, ts: 2 }); + session.recordCallStart(s, 2, { tool: "fetch", server: "web", arguments: { url: "x" }, ts: 3 }); + session.recordCallResult(s, 2, { result: { status: 200 }, ts: 4 }); + check("call-1-attributed-to-fs", s.calls[0].server === "fs" && s.calls[0].tool === "read", JSON.stringify(s.calls[0])); + check("call-2-attributed-to-web", s.calls[1].server === "web" && s.calls[1].tool === "fetch", JSON.stringify(s.calls[1])); +} + +function outOfOrderCorrelation() { + const s = session.createSession("conn-2"); + session.recordCallStart(s, "id-A", { tool: "slow", server: "srv", arguments: {}, ts: 10 }); + session.recordCallStart(s, "id-B", { tool: "fast", server: "srv", arguments: {}, ts: 11 }); + // Response for id-B (sent SECOND) arrives FIRST. + session.recordCallResult(s, "id-B", { result: { which: "B" }, ts: 20 }); + session.recordCallResult(s, "id-A", { result: { which: "A" }, ts: 21 }); + check("out-of-order-both-correlated", s.calls.length === 2, `expected 2 calls, got ${s.calls.length}`); + const byTool = Object.fromEntries(s.calls.map((c) => [c.tool, c.result])); + check("out-of-order-B-correlated-correctly", byTool.fast && byTool.fast.which === "B", JSON.stringify(byTool.fast)); + check("out-of-order-A-correlated-correctly", byTool.slow && byTool.slow.which === "A", JSON.stringify(byTool.slow)); + check("no-pending-left-after-both-resolved", s.pendingCalls.size === 0, `pendingCalls.size=${s.pendingCalls.size}`); +} + +function unmatchedResponseIsAnomalyNotCrash() { + const s = session.createSession("conn-3"); + let threw = null; + let correlated = null; + try { correlated = session.recordCallResult(s, "never-sent-id", { result: { x: 1 } }); } catch (error) { threw = error; } + check("unmatched-response-does-not-throw", threw === null, threw && threw.message); + check("unmatched-response-reports-not-correlated", correlated === false, String(correlated)); + check("unmatched-response-recorded-as-anomaly", s.anomalies.length === 1 && s.anomalies[0].kind === "UNMATCHED_RESPONSE", JSON.stringify(s.anomalies)); + check("unmatched-response-not-in-calls", s.calls.length === 0, `expected 0 calls, got ${s.calls.length}`); +} + +/** CodeRabbit/Codex PR #29 review round 8 "record unmatched downstream responses without + * agent-call correlation" / "keep unmatched downstream IDs out of agent correlation": a + * downstream response whose id has no matching pending call must be recorded as an + * anomaly WITHOUT ever touching pendingCalls -- proven directly here by picking a + * jsonRpcId that collides with a LIVE pending call and asserting that call survives + * untouched. recordCallResult (the pre-fix call site) would have deleted it. */ +function unmatchedResponseAnomalyDoesNotTouchPendingCalls() { + const s = session.createSession("conn-collision"); + session.recordCallStart(s, 1, { tool: "live-agent-call", server: "srv", arguments: {}, ts: 1 }); + const correlated = session.recordUnmatchedResponseAnomaly(s, 1, "downstream-internal id collided with an agent-facing pending call", 2); + check("unmatched-anomaly-helper-reports-recorded", correlated === true, String(correlated)); + check( + "unmatched-anomaly-does-not-delete-the-colliding-live-pending-call", + s.pendingCalls.has(1) && s.pendingCalls.get(1).tool === "live-agent-call", + JSON.stringify(Array.from(s.pendingCalls.entries())) + ); + check("unmatched-anomaly-recorded-once", s.anomalies.length === 1 && s.anomalies[0].kind === "UNMATCHED_RESPONSE" && s.anomalies[0].jsonRpcId === 1, JSON.stringify(s.anomalies)); + check("unmatched-anomaly-does-not-add-a-spurious-completed-call", s.calls.length === 0, JSON.stringify(s.calls)); + + // The live call, still genuinely pending, resolves normally afterward -- proving it was + // never corrupted or removed by the colliding anomaly recorded against the same id. + session.recordCallResult(s, 1, { result: { ok: true }, ts: 3 }); + check("colliding-live-call-still-resolves-normally-afterward", s.calls.length === 1 && s.calls[0].tool === "live-agent-call" && s.calls[0].isError === false, JSON.stringify(s.calls)); +} + +/** Codex PR #29 review round 8 "cap unmatched-response anomalies per session": without a + * bound, a stream of unmatched responses (a faulty/compromised downstream sending ids this + * gateway never issued) would grow session.anomalies without limit. */ +function anomaliesCappedWithTerminalMarker() { + const { MAX_ANOMALIES_PER_SESSION } = session; + const s = session.createSession("conn-anomaly-cap"); + for (let i = 0; i < MAX_ANOMALIES_PER_SESSION + 5; i++) { + session.recordUnmatchedResponseAnomaly(s, `unknown-${i}`, "test anomaly", i); + } + check( + "anomalies-stop-growing-past-the-cap-plus-one-terminal-marker", + s.anomalies.length === MAX_ANOMALIES_PER_SESSION + 1, + `expected ${MAX_ANOMALIES_PER_SESSION + 1}, got ${s.anomalies.length}` + ); + check("anomaly-cap-hit-is-itself-recorded-once", s.anomalies[s.anomalies.length - 1].kind === "ANOMALY_CAP_REACHED", JSON.stringify(s.anomalies[s.anomalies.length - 1])); + check( + "anomalies-before-the-cap-are-all-genuine-unmatched-responses", + s.anomalies.slice(0, MAX_ANOMALIES_PER_SESSION).every((a) => a.kind === "UNMATCHED_RESPONSE"), + "a non-UNMATCHED_RESPONSE entry appeared before the cap" + ); +} + +function disconnectMarksPendingAsError() { + const s = session.createSession("conn-4"); + session.recordCallStart(s, 1, { tool: "hangs", server: "srv", arguments: {}, ts: 1 }); + session.markPendingAsDisconnected(s, "downstream server \"srv\" disconnected"); + check("disconnect-moves-pending-into-calls", s.calls.length === 1, `expected 1 call, got ${s.calls.length}`); + check("disconnect-marks-isError-true", s.calls[0].isError === true, JSON.stringify(s.calls[0])); + check("disconnect-carries-explicit-marker", s.calls[0].disconnected === true && /disconnected/.test(s.calls[0].disconnect_reason), JSON.stringify(s.calls[0])); + check("disconnect-clears-pendingCalls", s.pendingCalls.size === 0, `pendingCalls.size=${s.pendingCalls.size}`); +} + +/** CodeRabbit PR #29 review round 4 "contain disconnect-callback failures during + * session finalization": onDisconnect is the CALLER's side effect (proxy.js wires it to + * a JSON.stringify + this.log() call, which console.error can in principle throw for -- + * e.g. an EPIPE on a closed stderr). Before this fix, a throwing onDisconnect would + * escape markPendingAsDisconnected entirely, aborting the loop for any remaining pending + * calls on this session and propagating up through closeConnection/ + * handleDownstreamDisconnect -- in gateway.js's shutdown drain this would abort + * finalizing every OTHER open session too and skip writerClaim.release(), leaking a + * stale claim. Two pending calls here prove both halves: the call whose callback throws + * is still correctly recorded (not lost), AND the loop continues to the next one rather + * than stopping. */ +function disconnectCallbackFailureDoesNotAbortLoopOrPropagate() { + const s = session.createSession("conn-callback-failure"); + session.recordCallStart(s, "first", { tool: "a", server: "srv", arguments: {}, ts: 1 }); + session.recordCallStart(s, "second", { tool: "b", server: "srv", arguments: {}, ts: 2 }); + const seen = []; + let threw = null; + try { + session.markPendingAsDisconnected(s, "downstream disconnected", undefined, undefined, (call) => { + seen.push(call.tool); + throw new Error("logger EPIPE (simulated)"); + }); + } catch (error) { + threw = error; + } + check("disconnect-callback-failure-does-not-propagate-out-of-markPendingAsDisconnected", threw === null, threw && threw.message); + check("disconnect-callback-still-invoked-for-every-pending-call-despite-throwing", seen.length === 2 && seen.includes("a") && seen.includes("b"), JSON.stringify(seen)); + check("disconnect-callback-failure-does-not-lose-the-call-record", s.calls.length === 2, `expected 2 calls, got ${s.calls.length}`); + check("disconnect-callback-failure-still-clears-pendingCalls", s.pendingCalls.size === 0, `pendingCalls.size=${s.pendingCalls.size}`); +} + +function modelCallFlagPreserved() { + const s = session.createSession("conn-5"); + session.recordCallStart(s, 1, { tool: "sampling/createMessage", server: "sampling", arguments: {}, isModelCall: true, ts: 1 }); + session.recordCallResult(s, 1, { result: {}, ts: 2 }); + check("model-call-flag-preserved-through-correlation", s.calls[0].model_call === true, JSON.stringify(s.calls[0])); +} + +function duplicateJsonRpcIdRejected() { + const s = session.createSession("conn-6"); + session.recordCallStart(s, 1, { tool: "a", server: "srv", arguments: {}, ts: 1 }); + let threw = null; + try { session.recordCallStart(s, 1, { tool: "b", server: "srv", arguments: {}, ts: 2 }); } catch (error) { threw = error; } + check("duplicate-jsonrpc-id-rejected", threw && threw.code === "DUPLICATE_JSONRPC_ID", threw && threw.code); +} + +function finalizeRefusesWithPendingCalls() { + const s = session.createSession("conn-7"); + session.recordCallStart(s, 1, { tool: "a", server: "srv", arguments: {}, ts: 1 }); + let threw = null; + try { session.finalizeSession(s, makeKeys()); } catch (error) { threw = error; } + check("finalize-refuses-with-pending-calls", threw && threw.code === "SESSION_HAS_PENDING_CALLS", threw && threw.code); +} + +function finalizeSealsAndVerifies() { + const { verifyBundle } = require(path.join(ROOT, "scripts", "gsa-verify.js")); + const kp = crypto.generateKeyPairSync("ed25519"); + const pem = kp.publicKey.export({ type: "spki", format: "pem" }).toString(); + const keys = { privateKey: kp.privateKey, signer: "gw-key", algo: "ed25519" }; + + const s = session.createSession("conn-8", { goal: "test session" }); + session.recordInitialize(s, { clientInfo: { name: "agent", version: "1" }, serverInfo: { name: "srv", version: "1" } }); + session.recordToolsList(s, [{ name: "t", server: "srv", schema: {} }]); + session.recordCallStart(s, 1, { tool: "t", server: "srv", arguments: { a: 1 }, ts: 1 }); + session.recordCallResult(s, 1, { result: { ok: true }, ts: 2 }); + + const sealed = session.finalizeSession(s, keys); + const verified = verifyBundle(sealed.bundle, { trustedKeys: { "gw-key": pem } }); + check("finalized-bundle-verifies", verified.status === "PASS", JSON.stringify(verified)); + check("finalize-marks-session-finalized", s.finalized === true, String(s.finalized)); + + let threw = null; + try { session.recordToolsList(s, []); } catch (error) { threw = error; } + check("cannot-record-into-finalized-session", threw && threw.code === "SESSION_FINALIZED", threw && threw.code); + + let threw2 = null; + try { session.finalizeSession(s, keys); } catch (error) { threw2 = error; } + check("cannot-finalize-twice", threw2 && threw2.code === "SESSION_FINALIZED", threw2 && threw2.code); +} + +/* PR #29 review thread 4000335147, frontier-panel review (4/4 converged) "flag + * structurally malformed tools/call results distinctly from tool-level errors": the + * predicate itself lives in proxy.js (isMalformedToolCallResult) -- this module's own + * job, tested here, is just to store whatever the caller computed (like isError) and + * carry it through to sealBoundaryBundle's trace line, additive-only. */ +function malformedResultFlagStoredAndPropagatedToSealedTrace() { + const keys = makeKeys(); + + const flagged = session.createSession("conn-malformed"); + session.recordCallStart(flagged, 1, { tool: "shapeless", server: "srv", arguments: {}, ts: 1 }); + session.recordCallResult(flagged, 1, { result: {}, malformedResult: true, ts: 2 }); + check("recordCallResult-stores-malformedResult-true", flagged.calls[0].malformedResult === true, JSON.stringify(flagged.calls)); + + const clean = session.createSession("conn-clean"); + session.recordCallStart(clean, 1, { tool: "clean", server: "srv", arguments: {}, ts: 1 }); + // malformedResult intentionally omitted from the recordCallResult options, exactly like + // every non-tools/call caller (e.g. sampling/createMessage) does -- must default to a + // plain false, never undefined/truthy. + session.recordCallResult(clean, 1, { result: { content: [] }, ts: 2 }); + check("recordCallResult-defaults-malformedResult-to-false-when-omitted", clean.calls[0].malformedResult === false, JSON.stringify(clean.calls)); + + const sealedFlagged = session.finalizeSession(flagged, keys); + const traceFlagged = sealedFlagged.bundle.contents["execution_trace.jsonl"]; + check("sealed-trace-carries-malformed-result-true-for-flagged-call", traceFlagged.includes("\"malformed_result\":true"), traceFlagged); + + const sealedClean = session.finalizeSession(clean, keys); + const traceClean = sealedClean.bundle.contents["execution_trace.jsonl"]; + check("sealed-trace-omits-malformed-result-key-for-well-formed-call-byte-parity", !traceClean.includes("malformed_result"), traceClean); +} + +function main() { + createSessionRejectsBadConnectionId(); + toolServerAttribution(); + outOfOrderCorrelation(); + unmatchedResponseIsAnomalyNotCrash(); + unmatchedResponseAnomalyDoesNotTouchPendingCalls(); + anomaliesCappedWithTerminalMarker(); + disconnectMarksPendingAsError(); + disconnectCallbackFailureDoesNotAbortLoopOrPropagate(); + modelCallFlagPreserved(); + malformedResultFlagStoredAndPropagatedToSealedTrace(); + duplicateJsonRpcIdRejected(); + finalizeRefusesWithPendingCalls(); + finalizeSealsAndVerifies(); + + const passed = results.filter((r) => r.status === "PASS").length; + const failed = results.filter((r) => r.status === "FAIL").length; + console.log(`SUMMARY passed=${passed} failed=${failed} skipped=0`); + process.exit(failures ? 1 : 0); +} + +main();