From 4e4382b866017b6309cd7acbf03672e5daba3d39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 25 Jun 2026 09:02:07 -0300 Subject: [PATCH 01/45] feat(THU-601): add thunderbolt-stdio-bridge CLI and curl|bash installer --- thunderbolt-stdio-bridge/.gitignore | 3 + thunderbolt-stdio-bridge/README.md | 113 ++++ thunderbolt-stdio-bridge/bin/cli.js | 205 ++++++++ thunderbolt-stdio-bridge/bin/cli.test.js | 217 ++++++++ thunderbolt-stdio-bridge/bun.lock | 294 +++++++++++ thunderbolt-stdio-bridge/install.sh | 57 +++ thunderbolt-stdio-bridge/package.json | 36 ++ .../scripts/build-cli.mjs | 43 ++ thunderbolt-stdio-bridge/src/args.js | 94 ++++ thunderbolt-stdio-bridge/src/args.test.js | 108 ++++ thunderbolt-stdio-bridge/src/child.js | 118 +++++ thunderbolt-stdio-bridge/src/child.test.js | 161 ++++++ thunderbolt-stdio-bridge/src/errors.js | 131 +++++ thunderbolt-stdio-bridge/src/errors.test.js | 83 +++ thunderbolt-stdio-bridge/src/log.js | 168 ++++++ thunderbolt-stdio-bridge/src/log.test.js | 128 +++++ .../src/mcp-server.integration.test.js | 95 ++++ thunderbolt-stdio-bridge/src/mcp-server.js | 311 +++++++++++ .../src/mcp-server.test.js | 441 ++++++++++++++++ thunderbolt-stdio-bridge/src/relay.js | 93 ++++ thunderbolt-stdio-bridge/src/relay.test.js | 88 ++++ thunderbolt-stdio-bridge/src/server.js | 205 ++++++++ thunderbolt-stdio-bridge/src/server.test.js | 483 ++++++++++++++++++ thunderbolt-stdio-bridge/src/tunnel.js | 112 ++++ thunderbolt-stdio-bridge/src/tunnel.test.js | 182 +++++++ thunderbolt-stdio-bridge/src/util.js | 79 +++ thunderbolt-stdio-bridge/src/util.test.js | 79 +++ 27 files changed, 4127 insertions(+) create mode 100644 thunderbolt-stdio-bridge/.gitignore create mode 100644 thunderbolt-stdio-bridge/README.md create mode 100644 thunderbolt-stdio-bridge/bin/cli.js create mode 100644 thunderbolt-stdio-bridge/bin/cli.test.js create mode 100644 thunderbolt-stdio-bridge/bun.lock create mode 100755 thunderbolt-stdio-bridge/install.sh create mode 100644 thunderbolt-stdio-bridge/package.json create mode 100644 thunderbolt-stdio-bridge/scripts/build-cli.mjs create mode 100644 thunderbolt-stdio-bridge/src/args.js create mode 100644 thunderbolt-stdio-bridge/src/args.test.js create mode 100644 thunderbolt-stdio-bridge/src/child.js create mode 100644 thunderbolt-stdio-bridge/src/child.test.js create mode 100644 thunderbolt-stdio-bridge/src/errors.js create mode 100644 thunderbolt-stdio-bridge/src/errors.test.js create mode 100644 thunderbolt-stdio-bridge/src/log.js create mode 100644 thunderbolt-stdio-bridge/src/log.test.js create mode 100644 thunderbolt-stdio-bridge/src/mcp-server.integration.test.js create mode 100644 thunderbolt-stdio-bridge/src/mcp-server.js create mode 100644 thunderbolt-stdio-bridge/src/mcp-server.test.js create mode 100644 thunderbolt-stdio-bridge/src/relay.js create mode 100644 thunderbolt-stdio-bridge/src/relay.test.js create mode 100644 thunderbolt-stdio-bridge/src/server.js create mode 100644 thunderbolt-stdio-bridge/src/server.test.js create mode 100644 thunderbolt-stdio-bridge/src/tunnel.js create mode 100644 thunderbolt-stdio-bridge/src/tunnel.test.js create mode 100644 thunderbolt-stdio-bridge/src/util.js create mode 100644 thunderbolt-stdio-bridge/src/util.test.js diff --git a/thunderbolt-stdio-bridge/.gitignore b/thunderbolt-stdio-bridge/.gitignore new file mode 100644 index 000000000..3c25e1e49 --- /dev/null +++ b/thunderbolt-stdio-bridge/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.log diff --git a/thunderbolt-stdio-bridge/README.md b/thunderbolt-stdio-bridge/README.md new file mode 100644 index 000000000..1993cd0bc --- /dev/null +++ b/thunderbolt-stdio-bridge/README.md @@ -0,0 +1,113 @@ + + +# thunderbolt-stdio-bridge + +Bridges a local **stdio** ACP or MCP server to a **loopback** network face so a +browser app can talk to it: + +- `--mode acp` — exposes the child over a **WebSocket** (`ws://127.0.0.1:PORT`). + Child stdout/stdin (NDJSON JSON-RPC) is pumped to/from a single WS client. +- `--mode mcp` — exposes the child over **Streamable HTTP** + (`http://127.0.0.1:PORT/mcp`) using the official `@modelcontextprotocol/sdk`. + +Everything the bridge needs to report — the readiness URL, warnings, the tunnel +URL, the generated bearer — goes to **stderr**. The bridge writes **nothing** to +its own stdout (the single exception is `--help`/`--version` text), so a parent +that treats the bridge as a stdio child can never have its framing corrupted. + +## Usage + +``` +thunderbolt-stdio-bridge --mode [options] -- [args...] +``` + +Everything after the first bare `--` is the child launch argv, passed verbatim +to `spawn`. + +```sh +# ACP: bridge a local stdio ACP agent to a loopback WebSocket +thunderbolt-stdio-bridge --mode acp -- node my-acp-agent.js +# stderr: ws://127.0.0.1:54123 + +# MCP: bridge a local stdio MCP server to a loopback HTTP face +thunderbolt-stdio-bridge --mode mcp -- npx @modelcontextprotocol/server-everything +# stderr: http://127.0.0.1:54124/mcp + +# MCP behind a public cloudflared quick tunnel (mints a mandatory bearer) +thunderbolt-stdio-bridge --mode mcp --tunnel -- npx some-mcp-server +``` + +### Options + +| Flag | Default | Meaning | +| -------------------- | ----------- | ----------------------------------------------------------------- | +| `--mode ` | _required_ | Which face to stand up. | +| `--host ` | `127.0.0.1` | Bind host. Non-loopback hosts trigger loud warnings. | +| `--port ` | `0` | Bind port. `0` lets the OS assign an ephemeral port. | +| `--allow-origin ` | — | Add an allowed browser `Origin` (repeatable). | +| `--allow-any-origin` | off | Disable the Origin gate entirely (warns loudly). | +| `--tunnel` | off | MCP only. Front the face with a cloudflared quick tunnel. | +| `--json` | off | Emit machine-readable JSON log lines (one object per line). | +| `--verbose` | off | Emit verbose diagnostic detail. | +| `--help`, `-h` | — | Print usage to stdout and exit `0`. | +| `--version`, `-V` | — | Print the version to stdout and exit `0`. | + +### Exit codes (sysexits) + +| Code | Meaning | +| ----- | ----------------------------------------------------------------- | +| `0` | Clean exit (child exited 0, `--help`, `--version`). | +| `64` | Usage error (bad/missing flags, empty launch argv). | +| `69` | Unavailable (cannot bind, spawn `ENOENT`, cloudflared missing). | +| `70` | Internal error (unexpected/uncaught). | +| `130` | Interrupted by `SIGINT`. | + +On any nonzero exit the bridge **SIGKILLs a live child first** — it never +orphans the process it spawned, and it never restarts it. + +## Security + +- **Origin gate is default-ON.** Browser WS upgrades / HTTP requests are checked + against an allowlist (loopback origins plus any explicit `--allow-origin`). + `--allow-any-origin` disables the gate and warns; combining it with a + non-loopback `--host` warns even louder. +- **`--tunnel` mints a mandatory bearer.** The bearer is high-entropy, printed + to **stderr only**, never embedded in the public URL or any query string, and + checked in constant time before any routing. +- **PII-safe logging.** Only allowlisted scalars are logged (method name, id + shape, origin, host, port, exit/error codes). Raw ACP/MCP frame bodies are + **never** logged. + +## Installation + +The bridge ships as a single self-contained `bridge.cjs` attached to a GitHub +release — there is **no npm publish**. `install.sh` downloads that artifact and +links it onto your `PATH` as `thunderbolt-stdio-bridge`: + +```sh +curl -fsSL https://raw.githubusercontent.com/thunderbird/thunderbolt/main/thunderbolt-stdio-bridge/install.sh | bash +``` + +The app invokes the published binary via `npx thunderbolt-stdio-bridge`. + +## Building + +`bun run build` runs `scripts/build-cli.mjs`, which bundles the CLI with esbuild +into `dist/bridge.cjs` (Node 18 target, `bufferutil`/`utf-8-validate` left +external, the version inlined from `package.json`, shebang prepended) and emits a +companion Windows `.cmd` launcher. + +## Development + +```sh +bun test # run the unit + offline-tolerant integration suite +``` + +Every external effect (spawn, the WebSocket server, `http.createServer`, the MCP +transport, the line reader, `process.exit`) is dependency-injected, so the unit +tests fake them with **zero real sockets**. The one integration test +(`src/mcp-server.integration.test.js`) drives the real +`@modelcontextprotocol/server-everything` through the official MCP client and +**skips gracefully** when the dependency or network is unavailable. diff --git a/thunderbolt-stdio-bridge/bin/cli.js b/thunderbolt-stdio-bridge/bin/cli.js new file mode 100644 index 000000000..001fc09d4 --- /dev/null +++ b/thunderbolt-stdio-bridge/bin/cli.js @@ -0,0 +1,205 @@ +#!/usr/bin/env node +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Composition root. Thin wiring only: parse argv, build the logger, validate, +// then dispatch to the ACP or MCP face, install signal handlers, and translate +// every outcome into a sysexits exit code. Every external collaborator is +// injectable via a single `deps` object so the whole root is testable with no +// real sockets. + +'use strict' + +const { parseArgs } = require('../src/args') +const { EX, toExitCode, toMessage, childExitToCode, UnavailableError } = require('../src/errors') +const { makeLogger } = require('../src/log') +const { insecureFlagWarnings } = require('../src/util') +const { startBridge } = require('../src/server') +const { startMcpFace } = require('../src/mcp-server') +const { startTunnel } = require('../src/tunnel') + +// esbuild inlines this; a fallback keeps the un-bundled bin runnable from source. +const BRIDGE_VERSION = typeof __BRIDGE_VERSION__ !== 'undefined' ? __BRIDGE_VERSION__ : '0.0.0-dev' + +const HELP_TEXT = `thunderbolt-stdio-bridge — bridge a local stdio ACP/MCP server to a loopback face. + +Usage: + thunderbolt-stdio-bridge --mode [options] -- ... + +Everything after \`--\` is the child launch argv, passed verbatim to spawn. + +Options: + --mode required; acp => WebSocket face, mcp => Streamable HTTP face + --host bind host (default 127.0.0.1) + --port bind port (default 0 = OS-assigned) + --allow-origin add an allowed Origin (repeatable) + --allow-any-origin disable the Origin gate (insecure; warns) + --tunnel expose the MCP face via a cloudflared quick tunnel (mcp only) + --json machine-readable diagnostics, one JSON object per line + --verbose extra diagnostic detail + -h, --help print this help and exit + -V, --version print the version and exit` + +/** + * Run the bridge CLI. Returns once the process outcome is decided; all exits go + * through the injected `exit` so tests assert the code without terminating. + * + * @param {Object} [opts] + * @param {string[]} [opts.argv] - argv without node/script (process.argv.slice(2)). + * @param {NodeJS.WritableStream} [opts.stdout] - help/version sink only. + * @param {NodeJS.WritableStream} [opts.stderr] - all diagnostics + banner. + * @param {(code: number) => void} [opts.exit] + * @param {Object} [opts.deps] - injectable { startBridge, startMcpFace, startTunnel, makeLogger, on, removeListener }. + * @returns {Promise} + */ +const run = async ({ + argv = process.argv.slice(2), + stdout = process.stdout, + stderr = process.stderr, + exit = process.exit, + deps = {}, +} = {}) => { + const _startBridge = deps.startBridge ?? startBridge + const _startMcpFace = deps.startMcpFace ?? startMcpFace + const _startTunnel = deps.startTunnel ?? startTunnel + const _makeLogger = deps.makeLogger ?? makeLogger + const onSignal = deps.on ?? process.on.bind(process) + const offSignal = deps.removeListener ?? process.removeListener.bind(process) + + // 1) Parse argv. Help/version short-circuit to stdout (no child, no framing). + const parsed = (() => { + try { + return parseArgs(argv) + } catch (err) { + return { error: err } + } + })() + + if (parsed.error) { + stderr.write(`${toMessage(parsed.error)}\n`) + return exit(toExitCode(parsed.error)) + } + if (parsed.help) { + stdout.write(`${HELP_TEXT}\n`) + return exit(EX.OK) + } + if (parsed.version) { + stdout.write(`${BRIDGE_VERSION}\n`) + return exit(EX.OK) + } + + const logger = _makeLogger({ json: parsed.json, verbose: parsed.verbose, sink: stderr }) + + // 3) Emit insecure-flag warnings before binding anything. + for (const line of insecureFlagWarnings({ + host: parsed.host, + allowAnyOrigin: parsed.allowAnyOrigin, + tunnel: parsed.tunnel, + })) { + logger.warn('insecure-flag', { code: line }) + } + + // Shared teardown state so every fatal path can SIGKILL a live child first. + const live = { face: null, tunnel: null } + + const reap = async () => { + // never-orphan: stop the face (which stops the child) and the tunnel. + if (live.face) await live.face.close().catch(() => {}) + if (live.tunnel) await live.tunnel.close().catch(() => {}) + } + + // The child exiting on its own drives the bridge's own exit code. + const onChildExit = async (info) => { + offSignal('SIGINT', sigintHandler) + offSignal('SIGTERM', sigtermHandler) + if (live.tunnel) await live.tunnel.close().catch(() => {}) + exit(childExitToCode(info)) + } + + // 6) One-shot signal handlers -> graceful stop -> derived exit code. + const handleSignal = (signal) => async () => { + offSignal('SIGINT', sigintHandler) + offSignal('SIGTERM', sigtermHandler) + await reap() + exit(signal === 'SIGINT' ? EX.SIGINT : EX.OK) + } + const sigintHandler = handleSignal('SIGINT') + const sigtermHandler = handleSignal('SIGTERM') + onSignal('SIGINT', sigintHandler) + onSignal('SIGTERM', sigtermHandler) + + // Never-orphan backstop for truly uncaught errors: SIGKILL the child + // synchronously (no async grace — the process is about to die) then exit 70. + const onFatal = (err) => { + offSignal('SIGINT', sigintHandler) + offSignal('SIGTERM', sigtermHandler) + if (live.face) live.face.kill() // immediate SIGKILL — never-orphan backstop + if (live.tunnel) live.tunnel.close().catch(() => {}) // best-effort + logger.error('uncaught', { code: err && err.code ? err.code : 'INTERNAL' }) + exit(EX.SOFTWARE) + } + onSignal('uncaughtException', onFatal) + onSignal('unhandledRejection', onFatal) + + try { + if (parsed.mode === 'acp') { + const face = await _startBridge({ + launch: parsed.launch, + host: parsed.host, + port: parsed.port, + allowOrigins: parsed.allowOrigins, + allowAnyOrigin: parsed.allowAnyOrigin, + logger, + onChildExit, + }) + live.face = face + // ACP face resolves on child exit via its own close(); cli derives the code + // from the child exit propagated by server.js. The face stays alive until a + // signal or child exit closes it; run() returns and the process is kept + // alive by the open server/sockets. + return + } + + // mode === 'mcp' + const bearerSource = parsed.tunnel + ? await (async () => { + const tunnel = await _startTunnel({ + localUrl: `http://127.0.0.1:${parsed.port}/mcp`, + logger, + }) + live.tunnel = tunnel + return tunnel.bearer + })() + : undefined + + const face = await _startMcpFace({ + launch: parsed.launch, + host: parsed.host, + port: parsed.port, + bearer: bearerSource, + allowOrigins: parsed.allowOrigins, + allowAnyOrigin: parsed.allowAnyOrigin, + logger, + onChildExit, + }) + live.face = face + return + } catch (err) { + await reap() // never-orphan before exiting on any fatal path + logger.error('fatal', { code: err instanceof UnavailableError ? err.code : 'INTERNAL' }) + stderr.write(`${toMessage(err)}\n`) + return exit(toExitCode(err)) + } +} + +module.exports = { run, childExitToCode } + +// Module side-effect entry: run when invoked as the program (not when required +// by a test). Bundled or executed directly, this is the program entrypoint. +if (require.main === module) { + run().catch((err) => { + process.stderr.write(`${toMessage(err)}\n`) + process.exit(toExitCode(err)) + }) +} diff --git a/thunderbolt-stdio-bridge/bin/cli.test.js b/thunderbolt-stdio-bridge/bin/cli.test.js new file mode 100644 index 000000000..7d46ed57c --- /dev/null +++ b/thunderbolt-stdio-bridge/bin/cli.test.js @@ -0,0 +1,217 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +'use strict' + +const { test, expect, mock } = require('bun:test') +const { run } = require('./cli') + +/** Collects writes to a fake stream. */ +const makeSink = () => { + const chunks = [] + return { write: (s) => chunks.push(s), text: () => chunks.join('') } +} + +/** Build a default deps bundle with spies the test can inspect/override. */ +const makeHarness = (over = {}) => { + const signals = {} + const face = { url: 'ws://127.0.0.1:5000', close: mock(async () => {}), kill: mock(() => {}) } + const tunnel = { publicUrl: 'https://x.trycloudflare.com', bearer: 'secret', close: mock(async () => {}) } + const startBridge = mock(async () => face) + const startMcpFace = mock(async () => face) + const startTunnel = mock(async () => tunnel) + const logger = { info: mock(() => {}), warn: mock(() => {}), error: mock(() => {}), banner: mock(() => {}) } + const makeLogger = mock(() => logger) + const exit = mock(() => {}) + const stdout = makeSink() + const stderr = makeSink() + const deps = { + startBridge, + startMcpFace, + startTunnel, + makeLogger, + on: (sig, fn) => { + signals[sig] = fn + }, + removeListener: () => {}, + ...over.deps, + } + return { face, tunnel, startBridge, startMcpFace, startTunnel, logger, makeLogger, exit, stdout, stderr, signals, deps } +} + +test('--help prints usage to stdout and exits 0 (no child spawned)', async () => { + const h = makeHarness() + await run({ argv: ['--help'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + expect(h.stdout.text()).toContain('Usage:') + expect(h.exit).toHaveBeenCalledWith(0) + expect(h.startBridge).not.toHaveBeenCalled() +}) + +test('--version prints the version and exits 0', async () => { + const h = makeHarness() + await run({ argv: ['--version'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + expect(h.stdout.text().trim().length).toBeGreaterThan(0) + expect(h.exit).toHaveBeenCalledWith(0) +}) + +test('missing --mode -> stderr usage message, exit 64, no spawn', async () => { + const h = makeHarness() + await run({ argv: ['--', 'node', 'a.js'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + expect(h.exit).toHaveBeenCalledWith(64) + expect(h.stderr.text().length).toBeGreaterThan(0) + expect(h.startBridge).not.toHaveBeenCalled() +}) + +test('--mode acp -- dispatches to startBridge with parsed launch + options', async () => { + const h = makeHarness() + await run({ + argv: ['--mode', 'acp', '--allow-origin', 'http://a', '--', 'node', 'agent.js'], + stdout: h.stdout, + stderr: h.stderr, + exit: h.exit, + deps: h.deps, + }) + expect(h.startBridge).toHaveBeenCalledTimes(1) + const arg = h.startBridge.mock.calls[0][0] + expect(arg.launch).toEqual(['node', 'agent.js']) + expect(arg.host).toBe('127.0.0.1') + expect(arg.allowOrigins).toEqual(['http://a']) +}) + +test('--mode mcp -- dispatches to startMcpFace; --tunnel invokes startTunnel and threads bearer', async () => { + const h = makeHarness() + await run({ + argv: ['--mode', 'mcp', '--tunnel', '--', 'srv'], + stdout: h.stdout, + stderr: h.stderr, + exit: h.exit, + deps: h.deps, + }) + expect(h.startTunnel).toHaveBeenCalledTimes(1) + expect(h.startMcpFace).toHaveBeenCalledTimes(1) + expect(h.startMcpFace.mock.calls[0][0].bearer).toBe('secret') +}) + +test('--mode mcp without --tunnel does not start a tunnel and bearer is undefined', async () => { + const h = makeHarness() + await run({ argv: ['--mode', 'mcp', '--', 'srv'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + expect(h.startTunnel).not.toHaveBeenCalled() + expect(h.startMcpFace.mock.calls[0][0].bearer).toBeUndefined() +}) + +test('SIGINT handler SIGKILLs (via face close) the live child then exits 130', async () => { + const h = makeHarness() + await run({ argv: ['--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + await h.signals.SIGINT() + expect(h.face.close).toHaveBeenCalledTimes(1) + expect(h.exit).toHaveBeenLastCalledWith(130) +}) + +test('SIGTERM handler closes the face then exits 0', async () => { + const h = makeHarness() + await run({ argv: ['--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + await h.signals.SIGTERM() + expect(h.face.close).toHaveBeenCalledTimes(1) + expect(h.exit).toHaveBeenLastCalledWith(0) +}) + +test('a bind failure from the face (UnavailableError EADDRINUSE) maps to exit 69 and reaps', async () => { + const err = Object.assign(new Error('addr'), { name: 'UnavailableError', code: 'EADDRINUSE' }) + const startBridge = mock(async () => { + throw err + }) + const h = makeHarness({ deps: { startBridge } }) + await run({ argv: ['--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + expect(h.exit).toHaveBeenLastCalledWith(69) +}) + +test('an unexpected internal throw maps to exit 70', async () => { + const startBridge = mock(async () => { + throw new Error('boom') + }) + const h = makeHarness({ deps: { startBridge } }) + await run({ argv: ['--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + expect(h.exit).toHaveBeenLastCalledWith(70) +}) + +test('logger is constructed with json/verbose flags and the injected stderr sink', async () => { + const h = makeHarness() + await run({ + argv: ['--mode', 'acp', '--json', '--verbose', '--', 'x'], + stdout: h.stdout, + stderr: h.stderr, + exit: h.exit, + deps: h.deps, + }) + expect(h.makeLogger).toHaveBeenCalledTimes(1) + const opts = h.makeLogger.mock.calls[0][0] + expect(opts.json).toBe(true) + expect(opts.verbose).toBe(true) + expect(opts.sink).toBe(h.stderr) +}) + +test('insecureFlagWarnings are emitted to stderr (via logger.warn) before the face starts', async () => { + const h = makeHarness() + let warnedBeforeStart = false + const startBridge = mock(async () => { + warnedBeforeStart = h.logger.warn.mock.calls.length > 0 + return h.face + }) + h.deps.startBridge = startBridge + await run({ + argv: ['--mode', 'acp', '--allow-any-origin', '--host', '0.0.0.0', '--', 'x'], + stdout: h.stdout, + stderr: h.stderr, + exit: h.exit, + deps: h.deps, + }) + expect(warnedBeforeStart).toBe(true) +}) + +test('on clean child exit the bridge exits with the child-derived code (0)', async () => { + let captured + const startBridge = mock(async (args) => { + captured = args + return h.face + }) + const h = makeHarness({ deps: { startBridge } }) + await run({ argv: ['--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + await captured.onChildExit({ code: 0, signal: null }) + expect(h.exit).toHaveBeenLastCalledWith(0) +}) + +test('a nonzero child exit derives exit 70', async () => { + let captured + const startBridge = mock(async (args) => { + captured = args + return h.face + }) + const h = makeHarness({ deps: { startBridge } }) + await run({ argv: ['--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + await captured.onChildExit({ code: 1, signal: null }) + expect(h.exit).toHaveBeenLastCalledWith(70) +}) + +test('uncaughtException SIGKILLs the live child (face.kill) and exits 70 (never-orphan)', async () => { + const h = makeHarness() + await run({ argv: ['--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + h.signals.uncaughtException(Object.assign(new Error('boom'), { code: 'ERR_FOO' })) + expect(h.face.kill).toHaveBeenCalledTimes(1) + expect(h.exit).toHaveBeenLastCalledWith(70) +}) + +test('unhandledRejection SIGKILLs the live child (face.kill) and exits 70 (never-orphan)', async () => { + const h = makeHarness() + await run({ argv: ['--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + h.signals.unhandledRejection(new Error('rejected')) + expect(h.face.kill).toHaveBeenCalledTimes(1) + expect(h.exit).toHaveBeenLastCalledWith(70) +}) + +test('--tunnel without --mode mcp is a usage error (exit 64)', async () => { + const h = makeHarness() + await run({ argv: ['--tunnel', '--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + expect(h.exit).toHaveBeenCalledWith(64) + expect(h.startBridge).not.toHaveBeenCalled() +}) diff --git a/thunderbolt-stdio-bridge/bun.lock b/thunderbolt-stdio-bridge/bun.lock new file mode 100644 index 000000000..a1ff67ce3 --- /dev/null +++ b/thunderbolt-stdio-bridge/bun.lock @@ -0,0 +1,294 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "thunderbolt-stdio-bridge", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "ws": "^8.18.0", + }, + "devDependencies": { + "@modelcontextprotocol/server-everything": "^2026.1.26", + "esbuild": "^0.24.0", + }, + "optionalDependencies": { + "bufferutil": "^4.0.8", + "utf-8-validate": "^6.0.4", + }, + }, + }, + "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.24.2", "", { "os": "android", "cpu": "x64" }, "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.2", "", { "os": "none", "cpu": "x64" }, "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="], + + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + + "@modelcontextprotocol/server-everything": ["@modelcontextprotocol/server-everything@2026.1.26", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", "cors": "^2.8.5", "express": "^5.2.1", "jszip": "^3.10.1", "zod": "^3.25.0", "zod-to-json-schema": "^3.23.5" }, "bin": { "mcp-server-everything": "dist/index.js" } }, "sha512-RIEXAQuKZeXZqFzJMqLDlzmMmG5sjq2uhS/VqzF4HIZ3w1V73YcWf+UXkxJueJuHMBO3Z8ceBrQ5wI6o/plZ+Q=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + + "bufferutil": ["bufferutil@4.1.0", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hono": ["hono@4.12.27", "", {}, "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "utf-8-validate": ["utf-8-validate@6.0.6", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + } +} diff --git a/thunderbolt-stdio-bridge/install.sh b/thunderbolt-stdio-bridge/install.sh new file mode 100755 index 000000000..b7b85cc29 --- /dev/null +++ b/thunderbolt-stdio-bridge/install.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# thunderbolt-stdio-bridge installer. +# +# Downloads the prebuilt bridge.cjs bundle from GitHub Releases and installs it +# as a bare command on the npm global bin dir (next to npm/npx). Requires node; +# no Bun, no registry publish, no runtime bundling. bridge.cjs already ships a +# `#!/usr/bin/env node` shebang, so dropping it in under the command name makes +# `thunderbolt-stdio-bridge ` behave like any global node CLI. +# +# curl -fsSL https://raw.githubusercontent.com/thunderbird/thunderbolt/main/thunderbolt-stdio-bridge/install.sh | bash +# +# Pin a version: ... | bash -s -- 0.1.0 +# Custom bin dir: THUNDERBOLT_BIN_DIR=/opt/bin ... | bash +set -euo pipefail + +REPO="thunderbird/thunderbolt" +CMD="thunderbolt-stdio-bridge" + +command -v node >/dev/null 2>&1 || { echo "error: node is required (https://nodejs.org)" >&2; exit 1; } +command -v npm >/dev/null 2>&1 || { echo "error: npm is required (ships with node)" >&2; exit 1; } + +# Install next to npm/npx (the npm global bin), or honor an explicit override. +BIN_DIR="${THUNDERBOLT_BIN_DIR:-$(npm prefix -g 2>/dev/null)/bin}" +[ -d "$BIN_DIR" ] || mkdir -p "$BIN_DIR" + +# Resolve the version: explicit arg/env wins; otherwise read main's package.json +# (always current, no GitHub API call so no rate limit). +VERSION="${1:-${THUNDERBOLT_STDIO_BRIDGE_VERSION:-}}" +if [ -z "$VERSION" ]; then + VERSION=$(curl -fsSL "https://raw.githubusercontent.com/$REPO/main/thunderbolt-stdio-bridge/package.json" \ + | sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' | head -n1) + [ -n "$VERSION" ] || { echo "error: could not resolve latest version" >&2; exit 1; } +fi +URL="https://github.com/$REPO/releases/download/stdio-bridge-v$VERSION/bridge.cjs" + +echo "Installing $CMD $VERSION -> $BIN_DIR/$CMD" + +# Download to a tmp file and move atomically — no half-written command on Ctrl-C. +TMP=$(mktemp) +trap 'rm -f "$TMP"' EXIT +curl -fL --progress-bar -o "$TMP" "$URL" +chmod +x "$TMP" + +# The npm global bin may need root (system node / Homebrew) — single-file move. +if [ -w "$BIN_DIR" ]; then + mv "$TMP" "$BIN_DIR/$CMD" +else + echo "$BIN_DIR is not writable — using sudo" + sudo mv "$TMP" "$BIN_DIR/$CMD" + sudo chmod +x "$BIN_DIR/$CMD" +fi + +echo "Installed. Run: $CMD --help" diff --git a/thunderbolt-stdio-bridge/package.json b/thunderbolt-stdio-bridge/package.json new file mode 100644 index 000000000..a945d42df --- /dev/null +++ b/thunderbolt-stdio-bridge/package.json @@ -0,0 +1,36 @@ +{ + "name": "thunderbolt-stdio-bridge", + "version": "0.1.0", + "description": "Bridges a local stdio ACP/MCP server to a loopback WebSocket (ACP) or Streamable HTTP (MCP) face so a browser app can reach it.", + "license": "MPL-2.0", + "type": "commonjs", + "bin": { + "thunderbolt-stdio-bridge": "bin/cli.js" + }, + "files": [ + "bin", + "src", + "dist", + "install.sh", + "README.md" + ], + "scripts": { + "build": "node scripts/build-cli.mjs", + "test": "bun test" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@modelcontextprotocol/server-everything": "^2026.1.26", + "esbuild": "^0.24.0" + }, + "optionalDependencies": { + "bufferutil": "^4.0.8", + "utf-8-validate": "^6.0.4" + } +} diff --git a/thunderbolt-stdio-bridge/scripts/build-cli.mjs b/thunderbolt-stdio-bridge/scripts/build-cli.mjs new file mode 100644 index 000000000..96fcf24fd --- /dev/null +++ b/thunderbolt-stdio-bridge/scripts/build-cli.mjs @@ -0,0 +1,43 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Bundles bin/cli.js (and its src/ deps) into a single self-contained +// dist/bridge.cjs that install.sh ships verbatim from GitHub Releases. esbuild +// inlines the package version as the __BRIDGE_VERSION__ global referenced by +// the CLI (so --version works in the bundle), prepends a node shebang so the +// bare file is directly executable, and keeps the native ws acceleration addons +// external (they are optional and resolved at runtime if present). + +import { build } from 'esbuild' +import { chmod, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const outfile = join(root, 'dist', 'bridge.cjs') + +const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) + +await build({ + entryPoints: [join(root, 'bin', 'cli.js')], + outfile, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node18', + // Native ws speedups: optional deps, loaded lazily by ws if installed. Keep + // them external so the bundle never hard-requires a compiled addon. + external: ['bufferutil', 'utf-8-validate'], + define: { __BRIDGE_VERSION__: JSON.stringify(pkg.version) }, + banner: { js: '#!/usr/bin/env node' }, +}) + +await chmod(outfile, 0o755) + +// Windows ignores the shebang and won't run a bare bridge.cjs from PATH, so emit +// a sibling .cmd shim that forwards every arg to node bridge.cjs. +const cmd = '@echo off\r\nnode "%~dp0bridge.cjs" %*\r\n' +await writeFile(join(root, 'dist', 'thunderbolt-stdio-bridge.cmd'), cmd) + +console.error(`built ${outfile} (v${pkg.version})`) diff --git a/thunderbolt-stdio-bridge/src/args.js b/thunderbolt-stdio-bridge/src/args.js new file mode 100644 index 000000000..86093f9ef --- /dev/null +++ b/thunderbolt-stdio-bridge/src/args.js @@ -0,0 +1,94 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict' + +const { UsageError } = require('./errors') +const { resolvePort } = require('./util') + +/** @typedef {{ mode: 'acp'|'mcp', host: string, port: number, allowOrigins: string[], allowAnyOrigin: boolean, tunnel: boolean, json: boolean, verbose: boolean, launch: string[] }} ParsedArgs */ + +const VALID_MODES = new Set(['acp', 'mcp']) + +/** + * True for a token that looks like a flag, so a flag expecting a value never + * silently swallows the next flag as its value. + * @param {string|undefined} token + */ +const looksLikeFlag = (token) => token !== undefined && token.startsWith('-') + +/** + * Pure argv parser. Splits flags from the child launch argv at the first bare + * `--`, validates flag values and cross-flag rules, and returns a fully + * resolved options object — or a `{help}`/`{version}` intent. Throws UsageError + * on any invalid input so the CLI maps it to exit 64. + * @param {string[]} argv + * @returns {ParsedArgs | { help: true } | { version: true }} + */ +const parseArgs = (argv) => { + if (argv.includes('--help') || argv.includes('-h')) return { help: true } + if (argv.includes('--version') || argv.includes('-V')) return { version: true } + + const delimiterIndex = argv.indexOf('--') + const flagArgs = delimiterIndex === -1 ? argv : argv.slice(0, delimiterIndex) + const launch = delimiterIndex === -1 ? [] : argv.slice(delimiterIndex + 1) + + /** @type {Partial} */ + const opts = { + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + tunnel: false, + json: false, + verbose: false, + } + + const takeValue = (flag, i) => { + const value = flagArgs[i + 1] + if (value === undefined || looksLikeFlag(value)) throw new UsageError(`${flag} requires a value`) + return value + } + + let i = 0 + while (i < flagArgs.length) { + const flag = flagArgs[i] + if (flag === '--mode') { + opts.mode = /** @type {'acp'|'mcp'} */ (takeValue(flag, i)) + i += 2 + } else if (flag === '--host') { + opts.host = takeValue(flag, i) + i += 2 + } else if (flag === '--port') { + opts.port = resolvePort(takeValue(flag, i)) + i += 2 + } else if (flag === '--allow-origin') { + opts.allowOrigins.push(takeValue(flag, i)) + i += 2 + } else if (flag === '--allow-any-origin') { + opts.allowAnyOrigin = true + i += 1 + } else if (flag === '--tunnel') { + opts.tunnel = true + i += 1 + } else if (flag === '--json') { + opts.json = true + i += 1 + } else if (flag === '--verbose') { + opts.verbose = true + i += 1 + } else { + throw new UsageError(`unknown flag ${flag}`) + } + } + + if (!opts.mode) throw new UsageError('--mode is required') + if (!VALID_MODES.has(opts.mode)) throw new UsageError(`--mode must be "acp" or "mcp", got "${opts.mode}"`) + if (opts.tunnel && opts.mode !== 'mcp') throw new UsageError('--tunnel requires --mode mcp') + if (launch.length === 0) throw new UsageError('missing child launch argv after "--"') + + return /** @type {ParsedArgs} */ ({ ...opts, launch }) +} + +module.exports = { parseArgs } diff --git a/thunderbolt-stdio-bridge/src/args.test.js b/thunderbolt-stdio-bridge/src/args.test.js new file mode 100644 index 000000000..f0b076278 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/args.test.js @@ -0,0 +1,108 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict' + +const { test, expect } = require('bun:test') +const { parseArgs } = require('./args') +const { UsageError } = require('./errors') + +test('--mode acp -- node agent.js → mode acp, launch=[node, agent.js]', () => { + const parsed = parseArgs(['--mode', 'acp', '--', 'node', 'agent.js']) + expect(parsed.mode).toBe('acp') + expect(parsed.launch).toEqual(['node', 'agent.js']) +}) + +test('--mode mcp --tunnel -- srv → tunnel true', () => { + expect(parseArgs(['--mode', 'mcp', '--tunnel', '--', 'srv']).tunnel).toBe(true) +}) + +test('--tunnel --mode acp -- x → UsageError (tunnel requires mcp)', () => { + expect(() => parseArgs(['--tunnel', '--mode', 'acp', '--', 'x'])).toThrow(UsageError) +}) + +test('missing --mode → UsageError', () => { + expect(() => parseArgs(['--', 'node', 'x.js'])).toThrow(UsageError) +}) + +test('--mode bogus → UsageError', () => { + expect(() => parseArgs(['--mode', 'bogus', '--', 'x'])).toThrow(UsageError) +}) + +test('no `--` delimiter → UsageError (empty launch)', () => { + expect(() => parseArgs(['--mode', 'acp'])).toThrow(UsageError) +}) + +test('`--` with nothing after → UsageError', () => { + expect(() => parseArgs(['--mode', 'acp', '--'])).toThrow(UsageError) +}) + +test('repeated --allow-origin a --allow-origin b → allowOrigins=[a,b]', () => { + const parsed = parseArgs(['--mode', 'acp', '--allow-origin', 'a', '--allow-origin', 'b', '--', 'x']) + expect(parsed.allowOrigins).toEqual(['a', 'b']) +}) + +test('--allow-any-origin sets the flag true', () => { + expect(parseArgs(['--mode', 'acp', '--allow-any-origin', '--', 'x']).allowAnyOrigin).toBe(true) +}) + +test('--port 8080 parses to 8080; --port 70000 / --port abc → UsageError', () => { + expect(parseArgs(['--mode', 'acp', '--port', '8080', '--', 'x']).port).toBe(8080) + expect(() => parseArgs(['--mode', 'acp', '--port', '70000', '--', 'x'])).toThrow(UsageError) + expect(() => parseArgs(['--mode', 'acp', '--port', 'abc', '--', 'x'])).toThrow(UsageError) +}) + +test('--host 0.0.0.0 retained verbatim', () => { + expect(parseArgs(['--mode', 'acp', '--host', '0.0.0.0', '--', 'x']).host).toBe('0.0.0.0') +}) + +test('--help returns {help:true} ignoring other flags; --version returns {version:true}', () => { + expect(parseArgs(['--mode', 'bogus', '--help'])).toEqual({ help: true }) + expect(parseArgs(['--version'])).toEqual({ version: true }) +}) + +test('-h and -V short aliases work', () => { + expect(parseArgs(['-h'])).toEqual({ help: true }) + expect(parseArgs(['-V'])).toEqual({ version: true }) +}) + +test('everything after the first `--` is preserved verbatim including further `--` and dashes', () => { + const parsed = parseArgs(['--mode', 'mcp', '--', 'npx', 'srv', '--', '--flag', '-x']) + expect(parsed.launch).toEqual(['npx', 'srv', '--', '--flag', '-x']) +}) + +test('unknown --frob → UsageError', () => { + expect(() => parseArgs(['--mode', 'acp', '--frob', '--', 'x'])).toThrow(UsageError) +}) + +test('--json and --verbose toggle their booleans; defaults are false', () => { + const on = parseArgs(['--mode', 'acp', '--json', '--verbose', '--', 'x']) + expect(on.json).toBe(true) + expect(on.verbose).toBe(true) + const off = parseArgs(['--mode', 'acp', '--', 'x']) + expect(off.json).toBe(false) + expect(off.verbose).toBe(false) +}) + +test('default host=127.0.0.1 and port=0 when omitted', () => { + const parsed = parseArgs(['--mode', 'acp', '--', 'x']) + expect(parsed.host).toBe('127.0.0.1') + expect(parsed.port).toBe(0) +}) + +test('flag expecting a value at end-of-argv → UsageError', () => { + expect(() => parseArgs(['--mode'])).toThrow(UsageError) +}) + +test('a flag value that itself looks like a flag is treated as a missing value → UsageError', () => { + expect(() => parseArgs(['--host', '--port', '--', 'x'])).toThrow(UsageError) +}) + +test('flags may appear in any order before `--`', () => { + const parsed = parseArgs(['--verbose', '--port', '3000', '--mode', 'mcp', '--json', '--', 'srv']) + expect(parsed.mode).toBe('mcp') + expect(parsed.port).toBe(3000) + expect(parsed.verbose).toBe(true) + expect(parsed.json).toBe(true) +}) diff --git a/thunderbolt-stdio-bridge/src/child.js b/thunderbolt-stdio-bridge/src/child.js new file mode 100644 index 000000000..cffa34353 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/child.js @@ -0,0 +1,118 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Shared child lifecycle owned by both faces. Spawns the launch argv exactly +// once, wires stdout/exit/error, and exposes the controls the faces need: +// write to stdin (with backpressure return), pause/resume stdout, graceful stop +// (signal then a grace window then SIGKILL), and an immediate SIGKILL. It NEVER +// restarts the child — on exit the supervisor reports once and stays dead. + +'use strict' + +const { spawn: defaultSpawn } = require('node:child_process') + +/** Time the child gets to exit on its own after a stop signal before SIGKILL. */ +const GRACE_MS = 2000 + +/** + * Spawn and supervise a single child process, exposing the controls the ACP/MCP + * faces need. The child is spawned exactly once and never respawned. + * + * @param {Object} opts + * @param {string[]} opts.launch - child launch argv: [program, ...args]. + * @param {Function} [opts.spawn] - injectable child_process.spawn. + * @param {(chunk: Buffer) => void} opts.onStdout - called per stdout data chunk. + * @param {(info: {code: number|null, signal: string|null}) => void} opts.onExit + * - called exactly once when the child exits. + * @param {(err: NodeJS.ErrnoException) => void} opts.onSpawnError - called on a + * spawn-level error (e.g. ENOENT); the child never reaches onExit-as-success. + * @param {{ error: Function }} opts.logger - PII-safe logger. + * @param {number} [opts.graceMs] - grace window before SIGKILL on stop(). + * @returns {{ child: import('node:child_process').ChildProcess, + * writeStdin(chunk: string|Buffer): boolean, pauseStdout(): void, + * resumeStdout(): void, stop(signal?: NodeJS.Signals): void, kill(): void, + * alive(): boolean }} + */ +const superviseChild = ({ + launch, + spawn = defaultSpawn, + onStdout, + onExit, + onSpawnError, + logger, + graceMs = GRACE_MS, +}) => { + // stdio: child stderr is inherited so its diagnostics pass straight through to + // the bridge's stderr and never pollute the bridge's stdout (sacred framing). + const child = spawn(launch[0], launch.slice(1), { stdio: ['pipe', 'pipe', 'inherit'] }) + + const state = { alive: true, exited: false } + let graceTimer = null + + const clearGrace = () => { + if (graceTimer) { + clearTimeout(graceTimer) + graceTimer = null + } + } + + child.on('error', (err) => { + state.alive = false + clearGrace() + onSpawnError(err) + }) + + child.stdout.on('data', onStdout) + + child.on('exit', (code, signal) => { + if (state.exited) return + state.exited = true + state.alive = false + clearGrace() + onExit({ code, signal }) + }) + + return { + child, + + writeStdin(chunk) { + if (!state.alive || child.stdin.destroyed || child.stdin.writableEnded) return true + return child.stdin.write(chunk) + }, + + pauseStdout() { + child.stdout.pause() + }, + + resumeStdout() { + child.stdout.resume() + }, + + stop(signal = 'SIGTERM') { + if (!state.alive) return + child.kill(signal) + clearGrace() + graceTimer = setTimeout(() => { + // Never-orphan: if the child ignored the signal, force it down. + if (state.alive) { + logger.error('child-grace-timeout', { code: 'SIGKILL' }) + child.kill('SIGKILL') + } + }, graceMs) + // Don't keep the event loop alive solely for the grace timer. + if (typeof graceTimer.unref === 'function') graceTimer.unref() + }, + + kill() { + clearGrace() + if (state.alive) child.kill('SIGKILL') + }, + + alive() { + return state.alive + }, + } +} + +module.exports = { superviseChild, GRACE_MS } diff --git a/thunderbolt-stdio-bridge/src/child.test.js b/thunderbolt-stdio-bridge/src/child.test.js new file mode 100644 index 000000000..ca1b042b9 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/child.test.js @@ -0,0 +1,161 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +'use strict' + +const { test, expect, mock } = require('bun:test') +const { EventEmitter } = require('node:events') +const { superviseChild } = require('./child') + +/** Minimal fake ChildProcess: EventEmitter with controllable stdin/stdout. */ +const makeFakeChild = () => { + const child = new EventEmitter() + child.killed = false + child.kill = mock((signal) => { + child.killed = true + child.lastSignal = signal + return true + }) + child.stdin = { + destroyed: false, + writableEnded: false, + write: mock(() => true), + } + child.stdout = new EventEmitter() + child.stdout.pause = mock(() => {}) + child.stdout.resume = mock(() => {}) + return child +} + +const noopLogger = { error: () => {}, warn: () => {}, info: () => {}, banner: () => {} } + +const baseOpts = (child, overrides = {}) => ({ + launch: ['node', 'agent.js'], + spawn: mock(() => child), + onStdout: () => {}, + onExit: () => {}, + onSpawnError: () => {}, + logger: noopLogger, + ...overrides, +}) + +test('spawns exactly once with launch[0] + args and stdio pipe/pipe/inherit', () => { + const child = makeFakeChild() + const spawn = mock(() => child) + superviseChild(baseOpts(child, { spawn })) + expect(spawn).toHaveBeenCalledTimes(1) + expect(spawn.mock.calls[0][0]).toBe('node') + expect(spawn.mock.calls[0][1]).toEqual(['agent.js']) + expect(spawn.mock.calls[0][2]).toEqual({ stdio: ['pipe', 'pipe', 'inherit'] }) +}) + +test('child stdout data is forwarded to onStdout', () => { + const child = makeFakeChild() + const onStdout = mock(() => {}) + superviseChild(baseOpts(child, { onStdout })) + const chunk = Buffer.from('hello') + child.stdout.emit('data', chunk) + expect(onStdout).toHaveBeenCalledTimes(1) + expect(onStdout.mock.calls[0][0]).toBe(chunk) +}) + +test('child exit fires onExit exactly once with {code,signal} and marks not-alive', () => { + const child = makeFakeChild() + const onExit = mock(() => {}) + const s = superviseChild(baseOpts(child, { onExit })) + expect(s.alive()).toBe(true) + child.emit('exit', 0, null) + child.emit('exit', 0, null) // second exit ignored + expect(onExit).toHaveBeenCalledTimes(1) + expect(onExit.mock.calls[0][0]).toEqual({ code: 0, signal: null }) + expect(s.alive()).toBe(false) +}) + +test('a spawn error (ENOENT) calls onSpawnError and never onExit-as-success', () => { + const child = makeFakeChild() + const onSpawnError = mock(() => {}) + const onExit = mock(() => {}) + const s = superviseChild(baseOpts(child, { onSpawnError, onExit })) + const err = Object.assign(new Error('not found'), { code: 'ENOENT' }) + child.emit('error', err) + expect(onSpawnError).toHaveBeenCalledTimes(1) + expect(onSpawnError.mock.calls[0][0]).toBe(err) + expect(onExit).not.toHaveBeenCalled() + expect(s.alive()).toBe(false) +}) + +test('writeStdin returns the underlying write boolean (false signals backpressure)', () => { + const child = makeFakeChild() + child.stdin.write = mock(() => false) + const s = superviseChild(baseOpts(child)) + expect(s.writeStdin('x')).toBe(false) + expect(child.stdin.write).toHaveBeenCalledTimes(1) +}) + +test('writeStdin after the child exits is a safe no-op returning true', () => { + const child = makeFakeChild() + const s = superviseChild(baseOpts(child)) + child.emit('exit', 0, null) + child.stdin.write = mock(() => false) + expect(s.writeStdin('x')).toBe(true) + expect(child.stdin.write).not.toHaveBeenCalled() +}) + +test('pauseStdout/resumeStdout pause and resume the child stdout stream', () => { + const child = makeFakeChild() + const s = superviseChild(baseOpts(child)) + s.pauseStdout() + s.resumeStdout() + expect(child.stdout.pause).toHaveBeenCalledTimes(1) + expect(child.stdout.resume).toHaveBeenCalledTimes(1) +}) + +test('stop() sends SIGTERM then SIGKILLs after the grace window if still alive', async () => { + const child = makeFakeChild() + const s = superviseChild(baseOpts(child, { graceMs: 10 })) + s.stop() + expect(child.kill).toHaveBeenLastCalledWith('SIGTERM') + await new Promise((r) => setTimeout(r, 25)) + expect(child.kill).toHaveBeenLastCalledWith('SIGKILL') +}) + +test('stop() does NOT SIGKILL if the child exits within the grace window', async () => { + const child = makeFakeChild() + const s = superviseChild(baseOpts(child, { graceMs: 50 })) + s.stop() + child.emit('exit', 0, 'SIGTERM') // child obeyed + await new Promise((r) => setTimeout(r, 70)) + const sigkills = child.kill.mock.calls.filter((c) => c[0] === 'SIGKILL') + expect(sigkills.length).toBe(0) +}) + +test('kill() sends SIGKILL immediately and is idempotent', () => { + const child = makeFakeChild() + const s = superviseChild(baseOpts(child)) + s.kill() + expect(child.kill).toHaveBeenLastCalledWith('SIGKILL') + const callsAfterFirst = child.kill.mock.calls.length + child.emit('exit', null, 'SIGKILL') + s.kill() // no-op after exit + expect(child.kill.mock.calls.length).toBe(callsAfterFirst) +}) + +test('NEVER respawns: a second exit does not trigger a new spawn', () => { + const child = makeFakeChild() + const spawn = mock(() => child) + superviseChild(baseOpts(child, { spawn })) + child.emit('exit', 0, null) + child.emit('exit', 1, null) + expect(spawn).toHaveBeenCalledTimes(1) +}) + +test('stop()/kill() after exit are no-ops', () => { + const child = makeFakeChild() + const s = superviseChild(baseOpts(child)) + child.emit('exit', 0, null) + const before = child.kill.mock.calls.length + s.stop() + s.kill() + expect(child.kill.mock.calls.length).toBe(before) +}) diff --git a/thunderbolt-stdio-bridge/src/errors.js b/thunderbolt-stdio-bridge/src/errors.js new file mode 100644 index 000000000..74dd0029c --- /dev/null +++ b/thunderbolt-stdio-bridge/src/errors.js @@ -0,0 +1,131 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict' + +/** + * Canonical sysexits exit-code table for the bridge. + * @type {{ OK: 0, USAGE: 64, UNAVAILABLE: 69, SOFTWARE: 70, SIGINT: 130 }} + */ +const EX = Object.freeze({ + OK: 0, + USAGE: 64, + UNAVAILABLE: 69, + SOFTWARE: 70, + SIGINT: 130, +}) + +/** + * Node error codes that classify as EX_UNAVAILABLE (69) — the single source of + * truth for the unavailable classification. + * @type {Set} + */ +const UNAVAILABLE_CODES = new Set(['ENOENT', 'EADDRINUSE', 'EACCES', 'EADDRNOTAVAIL', 'ECONNREFUSED']) + +/** Fixed, PII-safe phrases for each unavailable Node error code. */ +const UNAVAILABLE_PHRASES = Object.freeze({ + ENOENT: 'command not found', + EADDRINUSE: 'address in use', + EACCES: 'permission denied', + EADDRNOTAVAIL: 'address not available', + ECONNREFUSED: 'connection refused', +}) + +/** A usage/validation error → exit 64. Names the offending flag, never user payload. */ +class UsageError extends Error { + /** @param {string} message */ + constructor(message) { + super(message) + this.name = 'UsageError' + } +} + +/** A resource-unavailable error → exit 69. Carries a Node errorCode string. */ +class UnavailableError extends Error { + /** @param {{ code?: string, message?: string }} [opts] */ + constructor(opts = {}) { + super(opts.message ?? opts.code ?? 'unavailable') + this.name = 'UnavailableError' + /** @type {string|undefined} */ + this.code = opts.code + } +} + +/** A marker error for SIGINT-initiated teardown → exit 130. */ +class SigintError extends Error { + constructor() { + super('interrupted') + this.name = 'SigintError' + } +} + +/** + * Reads the Node error code (`err.code`) from an unknown value, if present. + * @param {unknown} err + * @returns {string|undefined} + */ +const codeOf = (err) => + typeof err === 'object' && err !== null && typeof (/** @type {{ code?: unknown }} */ (err).code) === 'string' + ? /** @type {{ code: string }} */ (err).code + : undefined + +/** + * Pure mapper from any thrown value to a sysexits exit code. + * UsageError→64; UnavailableError or a Node error in the unavailable set→69; + * a SIGINT marker→130; anything else→70. + * @param {unknown} err + * @returns {number} + */ +const toExitCode = (err) => { + if (err instanceof UsageError) return EX.USAGE + if (err instanceof SigintError) return EX.SIGINT + if (err instanceof UnavailableError) return EX.UNAVAILABLE + const code = codeOf(err) + if (code && UNAVAILABLE_CODES.has(code)) return EX.UNAVAILABLE + return EX.SOFTWARE +} + +/** + * Pure mapper to a PII-safe, single-line, user-facing message. Embeds only the + * Node errorCode (never err.message/stack for arbitrary errors), the failing + * flag name for UsageError, and a fixed friendly phrase for the unavailable set. + * @param {unknown} err + * @returns {string} + */ +const toMessage = (err) => { + if (err instanceof UsageError) return err.message + if (err instanceof SigintError) return 'interrupted' + if (err instanceof UnavailableError) { + const phrase = err.code ? UNAVAILABLE_PHRASES[err.code] : undefined + return phrase ?? (err.code ? `unavailable (${err.code})` : 'unavailable') + } + const code = codeOf(err) + if (code && UNAVAILABLE_CODES.has(code)) return UNAVAILABLE_PHRASES[code] + if (code) return `internal error (${code})` + return 'internal error' +} + +/** + * Pure: derive the bridge exit code from a child process exit. A clean exit + * (code 0) → 0; a SIGINT signal → 130; any other nonzero code or signal → 70. + * @param {{ code: number|null, signal: string|null }} exit + * @returns {number} + */ +const childExitToCode = (exit) => { + if (exit.signal === 'SIGINT') return EX.SIGINT + if (exit.signal) return EX.SOFTWARE + if (exit.code === 0) return EX.OK + return EX.SOFTWARE +} + +module.exports = { + EX, + UNAVAILABLE_CODES, + UsageError, + UnavailableError, + SigintError, + toExitCode, + toMessage, + childExitToCode, +} diff --git a/thunderbolt-stdio-bridge/src/errors.test.js b/thunderbolt-stdio-bridge/src/errors.test.js new file mode 100644 index 000000000..b6dfa8e73 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/errors.test.js @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict' + +const { test, expect } = require('bun:test') +const { EX, UsageError, UnavailableError, SigintError, toExitCode, toMessage, childExitToCode } = require('./errors') + +test('EX table values are exactly {0,64,69,70,130}', () => { + expect(EX).toEqual({ OK: 0, USAGE: 64, UNAVAILABLE: 69, SOFTWARE: 70, SIGINT: 130 }) +}) + +test('toExitCode(UsageError) === 64', () => { + expect(toExitCode(new UsageError('--mode is required'))).toBe(64) +}) + +test('toExitCode(UnavailableError EADDRINUSE) === 69', () => { + expect(toExitCode(new UnavailableError({ code: 'EADDRINUSE' }))).toBe(69) +}) + +test('toExitCode of a plain Error with code ENOENT === 69', () => { + const err = Object.assign(new Error('spawn failed'), { code: 'ENOENT' }) + expect(toExitCode(err)).toBe(69) +}) + +test('toExitCode of an unknown Error === 70', () => { + expect(toExitCode(new Error('boom'))).toBe(70) +}) + +test('toExitCode of a SIGINT marker === 130', () => { + expect(toExitCode(new SigintError())).toBe(130) +}) + +test('toExitCode of a thrown string === 70', () => { + expect(toExitCode('not an error')).toBe(70) +}) + +test('toMessage(UsageError) names the flag and no payload', () => { + const msg = toMessage(new UsageError('unknown flag --frob')) + expect(msg).toBe('unknown flag --frob') +}) + +test('toMessage of an ENOENT error says "command not found" with no err.message/path', () => { + const err = Object.assign(new Error('spawn /usr/secret/path ENOENT'), { code: 'ENOENT', path: '/usr/secret/path' }) + const msg = toMessage(err) + expect(msg).toBe('command not found') + expect(msg).not.toContain('/usr/secret/path') + expect(msg).not.toContain('spawn') +}) + +test('toMessage of an arbitrary Error contains only a generic phrase + errorCode, never the message text', () => { + const err = Object.assign(new Error('secret payload data 12345'), { code: 'EWEIRD' }) + const msg = toMessage(err) + expect(msg).toBe('internal error (EWEIRD)') + expect(msg).not.toContain('secret payload data') +}) + +test('toMessage of a codeless arbitrary Error is a fixed generic phrase', () => { + expect(toMessage(new Error('leak me'))).toBe('internal error') + expect(toMessage('a thrown string')).toBe('internal error') +}) + +test('toMessage of UnavailableError maps each unavailable code to a fixed phrase', () => { + expect(toMessage(new UnavailableError({ code: 'EADDRINUSE' }))).toBe('address in use') + expect(toMessage(new UnavailableError({ code: 'EACCES' }))).toBe('permission denied') +}) + +test('childExitToCode({code:0}) === 0', () => { + expect(childExitToCode({ code: 0, signal: null })).toBe(0) +}) + +test('childExitToCode({code:1}) === 70', () => { + expect(childExitToCode({ code: 1, signal: null })).toBe(70) +}) + +test('childExitToCode({signal:SIGINT}) === 130', () => { + expect(childExitToCode({ code: null, signal: 'SIGINT' })).toBe(130) +}) + +test('childExitToCode for a non-SIGINT signal === 70', () => { + expect(childExitToCode({ code: null, signal: 'SIGTERM' })).toBe(70) +}) diff --git a/thunderbolt-stdio-bridge/src/log.js b/thunderbolt-stdio-bridge/src/log.js new file mode 100644 index 000000000..6d8ead060 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/log.js @@ -0,0 +1,168 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict' + +const { isLoopbackHost } = require('./util') + +/** + * Field keys that may be logged. Everything else is dropped so no payload data + * leaks into a log line. + * @type {Set} + */ +const ALLOWED_FIELDS = new Set(['event', 'method', 'id', 'origin', 'host', 'port', 'code', 'errorCode', 'url']) + +/** + * Keep only allowlisted keys whose values are scalars (string/number/boolean). + * @param {Record|undefined} fields + * @returns {Record} + */ +const filterScalars = (fields) => { + const out = {} + if (!fields) return out + for (const key of Object.keys(fields)) { + if (!ALLOWED_FIELDS.has(key)) continue + const value = fields[key] + const type = typeof value + if (type === 'string' || type === 'number' || type === 'boolean') out[key] = value + } + return out +} + +/** Render a filtered field set as a compact `key=value` suffix. */ +const renderFields = (fields) => + Object.keys(fields) + .map((key) => `${key}=${fields[key]}`) + .join(' ') + +/** + * PII-safe structured logger writing to the injected sink (stderr) only. In + * `--json` mode each call emits one JSON line `{event, ...fields}`; otherwise a + * compact human line. `verbose=false` suppresses info detail but keeps + * warn/error. `banner()` prints the readiness line. + * @param {{ json: boolean, verbose: boolean, sink: NodeJS.WritableStream }} opts + */ +const makeLogger = ({ json, verbose, sink }) => { + const write = (level, event, fields) => { + const scalars = filterScalars(fields) + if (json) { + sink.write(`${JSON.stringify({ level, event, ...scalars })}\n`) + return + } + const suffix = renderFields(scalars) + sink.write(`[${level}] ${event}${suffix ? ` ${suffix}` : ''}\n`) + } + + return { + /** + * @param {string} event + * @param {Record} [fields] + */ + info(event, fields) { + if (!verbose) return + write('info', event, fields) + }, + /** + * @param {string} event + * @param {Record} [fields] + */ + warn(event, fields) { + write('warn', event, fields) + }, + /** + * @param {string} event + * @param {Record} [fields] + */ + error(event, fields) { + write('error', event, fields) + }, + /** + * Print the readiness line. Text mode prints the bare URL; JSON mode emits + * `{event:'listening'|'mcp-listening',url}` keyed off the scheme. + * @param {string} url + */ + banner(url) { + if (json) { + const event = url.startsWith('http') ? 'mcp-listening' : 'listening' + sink.write(`${JSON.stringify({ event, url })}\n`) + return + } + sink.write(`${url}\n`) + }, + } +} + +/** + * Normalize an Origin string to `scheme://host[:port]` (no path/trailing + * slash), or null if it can't be parsed. + * @param {string} origin + * @returns {string|null} + */ +const normalizeOrigin = (origin) => { + const parsed = URL.canParse(origin) ? new URL(origin) : null + if (!parsed) return null + return parsed.port + ? `${parsed.protocol}//${parsed.hostname}:${parsed.port}` + : `${parsed.protocol}//${parsed.hostname}` +} + +/** + * Build the Origin gate predicate. Always-true when `allowAnyOrigin`; otherwise + * true iff the Origin is absent (non-browser client), its host is loopback, or + * it exactly matches a normalized `allowOrigins` entry. A malformed Origin is + * rejected (unless allowAnyOrigin). + * @param {{ allowOrigins: string[], allowAnyOrigin: boolean }} opts + * @returns {(origin: string|undefined) => boolean} + */ +const buildOriginAllowlist = ({ allowOrigins, allowAnyOrigin }) => { + if (allowAnyOrigin) return () => true + const allowed = new Set(allowOrigins.map(normalizeOrigin).filter((o) => o !== null)) + return (origin) => { + if (origin === undefined || origin === '') return true + const normalized = normalizeOrigin(origin) + if (!normalized) return false + if (allowed.has(normalized)) return true + return isLoopbackHost(new URL(normalized).hostname) + } +} + +/** + * Extract ONLY the JSON-RPC method name from a parsed frame for logging. + * Returns 'unknown' if absent. Never returns params/result/payload. + * @param {unknown} frame + * @returns {string} + */ +const classifyMethod = (frame) => { + if (typeof frame === 'object' && frame !== null) { + const method = /** @type {{ method?: unknown }} */ (frame).method + if (typeof method === 'string') return method + } + return 'unknown' +} + +/** + * Classify a JSON-RPC frame's id into a non-identifying shape token, never the + * id value itself: 'request' (has id + method), 'response' (has id, no method), + * 'notification' (method, no id), or 'absent'. + * @param {unknown} frame + * @returns {'request'|'response'|'notification'|'absent'} + */ +const classifyId = (frame) => { + if (typeof frame !== 'object' || frame === null) return 'absent' + const obj = /** @type {{ id?: unknown, method?: unknown }} */ (frame) + const hasId = obj.id !== undefined && obj.id !== null + const hasMethod = typeof obj.method === 'string' + if (hasId && hasMethod) return 'request' + if (hasId) return 'response' + if (hasMethod) return 'notification' + return 'absent' +} + +module.exports = { + makeLogger, + buildOriginAllowlist, + classifyMethod, + classifyId, + normalizeOrigin, +} diff --git a/thunderbolt-stdio-bridge/src/log.test.js b/thunderbolt-stdio-bridge/src/log.test.js new file mode 100644 index 000000000..645a35eea --- /dev/null +++ b/thunderbolt-stdio-bridge/src/log.test.js @@ -0,0 +1,128 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict' + +const { test, expect } = require('bun:test') +const { makeLogger, buildOriginAllowlist, classifyMethod, classifyId } = require('./log') + +/** A fake writable sink that records every written line. */ +const makeSink = () => { + const lines = [] + return { + lines, + write(chunk) { + lines.push(chunk) + return true + }, + } +} + +test('json mode emits one parseable JSON object per call with event + allowlisted scalars only', () => { + const sink = makeSink() + const logger = makeLogger({ json: true, verbose: true, sink }) + logger.info('frame', { method: 'initialize', id: 'request', port: 5000 }) + expect(sink.lines).toHaveLength(1) + const parsed = JSON.parse(sink.lines[0]) + expect(parsed).toEqual({ level: 'info', event: 'frame', method: 'initialize', id: 'request', port: 5000 }) +}) + +test('text mode emits a single human line; verbose=false suppresses info but keeps warn/error', () => { + const sink = makeSink() + const logger = makeLogger({ json: false, verbose: false, sink }) + logger.info('skipped', { method: 'tools/list' }) + expect(sink.lines).toHaveLength(0) + logger.warn('insecure', { host: '0.0.0.0' }) + logger.error('failed', { errorCode: 'EADDRINUSE' }) + expect(sink.lines).toHaveLength(2) + expect(sink.lines[0]).toBe('[warn] insecure host=0.0.0.0\n') + expect(sink.lines[1]).toBe('[error] failed errorCode=EADDRINUSE\n') +}) + +test('a fields object with a nested object or non-allowlisted key is stripped before output', () => { + const sink = makeSink() + const logger = makeLogger({ json: true, verbose: true, sink }) + logger.info('frame', { method: 'x', params: { secret: 'leak' }, nested: { a: 1 }, password: 'hunter2' }) + const parsed = JSON.parse(sink.lines[0]) + expect(parsed).toEqual({ level: 'info', event: 'frame', method: 'x' }) + expect(sink.lines[0]).not.toContain('leak') + expect(sink.lines[0]).not.toContain('hunter2') +}) + +test('logger writes only to the injected sink', () => { + const sink = makeSink() + const logger = makeLogger({ json: false, verbose: true, sink }) + logger.warn('w') + expect(sink.lines).toHaveLength(1) +}) + +test('banner prints the url (text) and {event:listening,url} (json) for ws', () => { + const textSink = makeSink() + makeLogger({ json: false, verbose: false, sink: textSink }).banner('ws://127.0.0.1:5000') + expect(textSink.lines[0]).toBe('ws://127.0.0.1:5000\n') + + const jsonSink = makeSink() + makeLogger({ json: true, verbose: false, sink: jsonSink }).banner('ws://127.0.0.1:5000') + expect(JSON.parse(jsonSink.lines[0])).toEqual({ event: 'listening', url: 'ws://127.0.0.1:5000' }) +}) + +test('banner for mcp prints {event:mcp-listening,url} in json', () => { + const sink = makeSink() + makeLogger({ json: true, verbose: false, sink }).banner('http://127.0.0.1:5000/mcp') + expect(JSON.parse(sink.lines[0])).toEqual({ event: 'mcp-listening', url: 'http://127.0.0.1:5000/mcp' }) +}) + +test('buildOriginAllowlist with allowAnyOrigin returns true for any origin including evil.com', () => { + const allow = buildOriginAllowlist({ allowOrigins: [], allowAnyOrigin: true }) + expect(allow('http://evil.com')).toBe(true) + expect(allow(undefined)).toBe(true) +}) + +test('default allowlist accepts undefined Origin and loopback origins', () => { + const allow = buildOriginAllowlist({ allowOrigins: [], allowAnyOrigin: false }) + expect(allow(undefined)).toBe(true) + expect(allow('')).toBe(true) + expect(allow('http://localhost:3000')).toBe(true) + expect(allow('http://127.0.0.1:5173')).toBe(true) + expect(allow('http://[::1]:8080')).toBe(true) +}) + +test('default allowlist rejects http://evil.com and malformed origins', () => { + const allow = buildOriginAllowlist({ allowOrigins: [], allowAnyOrigin: false }) + expect(allow('http://evil.com')).toBe(false) + expect(allow('not a url')).toBe(false) +}) + +test('an explicit --allow-origin entry matches regardless of trailing path/slash', () => { + const allow = buildOriginAllowlist({ allowOrigins: ['https://app.example.com/'], allowAnyOrigin: false }) + expect(allow('https://app.example.com')).toBe(true) + expect(allow('https://app.example.com/some/path')).toBe(true) + expect(allow('https://other.example.com')).toBe(false) +}) + +test('classifyMethod returns the method name and never params/result', () => { + expect(classifyMethod({ jsonrpc: '2.0', method: 'tools/call', params: { secret: 'x' } })).toBe('tools/call') +}) + +test('classifyMethod of a frame without method → unknown', () => { + expect(classifyMethod({ id: 1, result: { data: 'x' } })).toBe('unknown') + expect(classifyMethod(null)).toBe('unknown') + expect(classifyMethod('string')).toBe('unknown') +}) + +test('classifyId distinguishes request/response/notification/absent without leaking the id value', () => { + expect(classifyId({ id: 42, method: 'initialize' })).toBe('request') + expect(classifyId({ id: 42, result: {} })).toBe('response') + expect(classifyId({ method: 'notifications/progress' })).toBe('notification') + expect(classifyId({})).toBe('absent') +}) + +test('error() logs the passed errorCode and never a message/stack string', () => { + const sink = makeSink() + const logger = makeLogger({ json: true, verbose: false, sink }) + logger.error('child-spawn', { errorCode: 'ENOENT' }) + const parsed = JSON.parse(sink.lines[0]) + expect(parsed.errorCode).toBe('ENOENT') + expect(sink.lines[0]).not.toContain('stack') +}) diff --git a/thunderbolt-stdio-bridge/src/mcp-server.integration.test.js b/thunderbolt-stdio-bridge/src/mcp-server.integration.test.js new file mode 100644 index 000000000..98eb00dd9 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/mcp-server.integration.test.js @@ -0,0 +1,95 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict' + +// Offline-tolerant integration test: drives the REAL +// @modelcontextprotocol/server-everything child through the OFFICIAL MCP client +// over the real HTTP face, with no mocks. It resolves server-everything locally +// (no network) and SKIPS the whole suite when the dependency is unavailable, so +// CI stays green offline. + +const { test, describe, expect, beforeAll, afterAll } = require('bun:test') +const path = require('node:path') +const { Client } = require('@modelcontextprotocol/sdk/client/index.js') +const { StreamableHTTPClientTransport } = require('@modelcontextprotocol/sdk/client/streamableHttp.js') +const { makeLogger } = require('./log') +const { startMcpFace } = require('./mcp-server') + +/** + * Resolve a launch argv for server-everything from the locally-installed package + * (no network). Returns null when the dependency isn't installed, so the suite + * skips rather than failing offline. + * @returns {string[]|null} + */ +const resolveServerEverythingLaunch = () => { + try { + const pkgPath = require.resolve('@modelcontextprotocol/server-everything/package.json') + const pkg = require(pkgPath) + const dir = path.dirname(pkgPath) + const binEntry = + typeof pkg.bin === 'string' ? pkg.bin : (pkg.bin?.['mcp-server-everything'] ?? Object.values(pkg.bin ?? {})[0]) + const bin = binEntry ? path.resolve(dir, binEntry) : path.resolve(dir, pkg.main ?? 'dist/index.js') + return [process.execPath, bin, 'stdio'] + } catch { + return null + } +} + +const launch = resolveServerEverythingLaunch() +const unavailable = launch === null + +describe.skipIf(unavailable)('mcp-server integration (real server-everything)', () => { + /** @type {{ url: string, close(): Promise }|null} */ + let face = null + /** @type {Client|null} */ + let client = null + + beforeAll(async () => { + const logger = makeLogger({ json: false, verbose: false, sink: process.stderr }) + face = await startMcpFace({ + launch, + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + logger, + }) + client = new Client({ name: 'integration-test', version: '0.0.0' }) + const transport = new StreamableHTTPClientTransport(new URL(face.url)) + await client.connect(transport) + }) + + afterAll(async () => { + // Always teardown so no child/socket leaks even on assertion failure. + if (client) await client.close().catch(() => {}) + if (face) await face.close() + }) + + test('initialize handshake succeeds through the face', () => { + expect(client).not.toBeNull() + expect(client.getServerVersion()).toBeTruthy() + }) + + test('tools/list returns a non-empty tool set', async () => { + const { tools } = await client.listTools() + expect(Array.isArray(tools)).toBe(true) + expect(tools.length).toBeGreaterThan(0) + }) + + test('calling the echo tool returns a well-formed result', async () => { + const { tools } = await client.listTools() + const echo = tools.find((t) => t.name === 'echo') + expect(echo).toBeTruthy() + const result = await client.callTool({ name: 'echo', arguments: { message: 'hello bridge' } }) + const text = (result.content ?? []).map((c) => c.text ?? '').join('') + expect(text).toContain('hello bridge') + }) +}) + +test.skipIf(!unavailable)('skips gracefully when server-everything is unavailable', () => { + // A placeholder so the file reports a (passing) result offline instead of an + // empty run — documents that the skip path is intentional, not a silent gap. + expect(unavailable).toBe(true) +}) diff --git a/thunderbolt-stdio-bridge/src/mcp-server.js b/thunderbolt-stdio-bridge/src/mcp-server.js new file mode 100644 index 000000000..cfd805da5 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/mcp-server.js @@ -0,0 +1,311 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// The MCP Streamable HTTP face. Stands up a bare @modelcontextprotocol/sdk +// StreamableHTTPServerTransport behind a minimal http.createServer on host:port +// (default 127.0.0.1) and bridges it to the spawned stdio MCP child: HTTP-side +// JSON-RPC messages are written to the child as NDJSON (relay.wsToFrame) and the +// child's NDJSON stdout lines are pushed back to HTTP clients (transport.send). +// Enforces bearer-before-route, CORS per the Origin allowlist, a request body +// cap, and deterministic never-orphan teardown. Prints the +// `http://127.0.0.1:PORT/mcp` banner to stderr once listening. + +'use strict' + +const { createServer: defaultCreateServer } = require('node:http') +const { createHash, timingSafeEqual } = require('node:crypto') +const { randomUUID } = require('node:crypto') +const { + StreamableHTTPServerTransport: DefaultStreamableHTTPServerTransport, +} = require('@modelcontextprotocol/sdk/server/streamableHttp.js') +const { UnavailableError } = require('./errors') +const { buildOriginAllowlist, classifyMethod, classifyId } = require('./log') +const { createNdjsonReader, wsToFrame } = require('./relay') +const { superviseChild: defaultSuperviseChild } = require('./child') +const { formatHostForUrl } = require('./util') + +/** Default request body cap: 4 MiB. Larger POST bodies are rejected with 413. */ +const DEFAULT_BODY_CAP_BYTES = 4 << 20 +/** The single MCP endpoint path the face serves. */ +const MCP_PATH = '/mcp' +/** Methods the transport handles on /mcp. Everything else is 404. */ +const MCP_METHODS = new Set(['POST', 'GET', 'DELETE']) + +/** + * Constant-time bearer comparison that never leaks length. Both tokens are + * SHA-256 digested to fixed 32-byte buffers before timingSafeEqual, so unequal + * input lengths can never throw or short-circuit. + * @param {string|undefined} provided + * @param {string} expected + * @returns {boolean} + */ +const bearerMatches = (provided, expected) => { + if (typeof provided !== 'string') return false + const a = createHash('sha256').update(provided).digest() + const b = createHash('sha256').update(expected).digest() + return timingSafeEqual(a, b) +} + +/** Extract the `Bearer ` value from an Authorization header, or undefined. */ +const readBearer = (req) => { + const header = req.headers.authorization + if (typeof header !== 'string') return undefined + const match = /^Bearer (.+)$/.exec(header) + return match ? match[1] : undefined +} + +/** Reply with a status code and no body leak. */ +const replyStatus = (res, status) => { + res.writeHead(status) + res.end() +} + +/** Sentinel returned by readBody when the aggregate body exceeds the cap. */ +const BODY_TOO_LARGE = Symbol('body-too-large') +/** Sentinel returned by readBody when the client aborts mid-body (error/early close). */ +const BODY_ABORTED = Symbol('body-aborted') +/** Sentinel returned by parseBody for a non-empty body that isn't valid JSON. */ +const MALFORMED = Symbol('malformed-body') + +/** + * Parse a POST body to a JSON value. An empty body → undefined (a bodyless GET- + * style POST); a non-empty body that isn't valid JSON → the MALFORMED sentinel + * so the caller answers 400 instead of crashing the handler. + * @param {string} body + * @returns {unknown} + */ +const parseBody = (body) => { + if (body === '') return undefined + try { + return JSON.parse(body) + } catch { + return MALFORMED + } +} + +/** + * Read the request body up to `cap` bytes. Resolves the buffered string, the + * BODY_TOO_LARGE sentinel once the aggregate exceeds the cap, or the BODY_ABORTED + * sentinel if the client aborts mid-body (socket error, or close before 'end'). + * It NEVER rejects (a rejection here is an unawaited promise on the http 'request' + * listener — Node's default unhandledRejection would kill the process without + * reaping the child, orphaning it) and never hangs (a premature 'close' that + * never emits 'end' resolves BODY_ABORTED). On overflow it stops buffering and + * drains the rest of the stream (req.resume) so the socket stays healthy and the + * caller's 413 response flushes cleanly — it does NOT destroy the socket (that + * would surface as a client-side connection error). + * @param {import('node:http').IncomingMessage} req + * @param {number} cap + * @returns {Promise} + */ +const readBody = (req, cap) => + new Promise((resolve) => { + const chunks = [] + let size = 0 + let overflowed = false + let settled = false + const settle = (value) => { + if (settled) return + settled = true + resolve(value) + } + req.on('data', (chunk) => { + if (overflowed) return + size += chunk.length + if (size > cap) { + overflowed = true + chunks.length = 0 + req.resume() // drain remaining bytes without buffering + return + } + chunks.push(chunk) + }) + req.on('end', () => settle(overflowed ? BODY_TOO_LARGE : Buffer.concat(chunks).toString('utf8'))) + req.on('error', () => settle(BODY_ABORTED)) + // 'close' fires after 'end' on a clean request (settle() is then a no-op); a + // 'close' before 'end' means the client aborted ⇒ resolve BODY_ABORTED. + req.on('close', () => settle(BODY_ABORTED)) + }) + +/** + * Start the MCP Streamable HTTP face: bind, spawn the child MCP stdio server, + * connect a bare StreamableHTTPServerTransport, and relay JSON-RPC between the + * HTTP face and the child's NDJSON stdio. Bearer-before-route, CORS, body cap, + * deterministic teardown, never-orphan. + * + * @param {Object} opts + * @param {string[]} opts.launch - child launch argv. + * @param {string} opts.host + * @param {number} opts.port - 0 => OS-assigned ephemeral. + * @param {string} [opts.bearer] - when set (always under --tunnel), gates every route. + * @param {string[]} opts.allowOrigins + * @param {boolean} opts.allowAnyOrigin + * @param {number} [opts.bodyCapBytes] + * @param {Object} opts.logger + * @param {(info: {code: number|null, signal: string|null}) => void} [opts.onChildExit] + * - notified when the child exits so the caller can derive its exit code. + * @param {Object} [opts.deps] - injectable { createServer, StreamableHTTPServerTransport, superviseChild, spawn }. + * @returns {Promise<{ url: string, kill(): void, close(): Promise }>} + */ +const startMcpFace = ({ + launch, + host, + port, + bearer, + allowOrigins, + allowAnyOrigin, + bodyCapBytes = DEFAULT_BODY_CAP_BYTES, + logger, + onChildExit, + deps = {}, +}) => { + const createServer = deps.createServer ?? defaultCreateServer + const StreamableHTTPServerTransport = deps.StreamableHTTPServerTransport ?? DefaultStreamableHTTPServerTransport + const superviseChild = deps.superviseChild ?? defaultSuperviseChild + const isOriginAllowed = buildOriginAllowlist({ allowOrigins, allowAnyOrigin }) + + return new Promise((resolve, reject) => { + const closers = { settled: false, resolveClose: null } + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }) + + // HTTP -> child: every JSON-RPC message the transport surfaces is written to + // the child's stdin as one NDJSON line. Malformed (unserializable) frames are + // dropped and logged by method/id only — the raw frame is never logged. + transport.onmessage = (message) => { + try { + supervisor.writeStdin(wsToFrame(JSON.stringify(message))) + } catch { + logger.warn('drop-http-frame', { method: classifyMethod(message), id: classifyId(message) }) + } + } + + // child stdout NDJSON -> HTTP: each complete line is parsed and pushed to the + // transport, which routes it to the correct pending HTTP/SSE response. + const reader = createNdjsonReader((line) => { + const message = (() => { + try { + return JSON.parse(line) + } catch { + logger.warn('drop-child-frame', { method: 'unknown', id: 'absent' }) + return null + } + })() + if (message) transport.send(message) + }) + + const server = createServer() + + const finishClose = () => { + if (closers.settled) return + closers.settled = true + if (closers.resolveClose) closers.resolveClose() + } + + const supervisor = superviseChild({ + launch, + spawn: deps.spawn, + logger, + onStdout: (chunk) => reader.push(chunk), + onExit: (info) => { + reader.flush() + transport.close() + server.close(finishClose) + // Force lingering keep-alive sockets closed so finishClose fires promptly. + if (typeof server.closeAllConnections === 'function') server.closeAllConnections() + if (onChildExit) onChildExit(info) + }, + onSpawnError: (err) => { + server.close() + if (!closers.settled) reject(new UnavailableError({ code: err.code })) + }, + }) + + const applyCors = (req, res) => { + const origin = req.headers.origin + if (allowAnyOrigin) { + res.setHeader('Access-Control-Allow-Origin', '*') + } else if (typeof origin === 'string' && isOriginAllowed(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin) + res.setHeader('Vary', 'Origin') + } + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Mcp-Session-Id, Accept') + res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id') + } + + server.on('request', async (req, res) => { + // BEARER-BEFORE-ROUTE: the very first check, before CORS/routing/parsing. + if (bearer !== undefined && !bearerMatches(readBearer(req), bearer)) { + replyStatus(res, 401) + return + } + + applyCors(req, res) + if (req.method === 'OPTIONS') { + replyStatus(res, 204) + return + } + + const path = (req.url ?? '').split('?')[0] + if (path !== MCP_PATH || !MCP_METHODS.has(req.method)) { + replyStatus(res, 404) + return + } + + if (req.method === 'POST') { + const body = await readBody(req, bodyCapBytes) + if (body === BODY_ABORTED) return // socket is gone; no reply to write + if (body === BODY_TOO_LARGE) { + replyStatus(res, 413) + return + } + const parsed = parseBody(body) + if (parsed === MALFORMED) { + replyStatus(res, 400) + return + } + transport.handleRequest(req, res, parsed) + return + } + + transport.handleRequest(req, res) + }) + + server.on('error', (err) => { + // Bind failures (EADDRINUSE/EACCES) arrive here before 'listening'. + supervisor.kill() // never-orphan + server.close() + reject(new UnavailableError({ code: err.code })) + }) + + server.listen(port, host, () => { + const actualPort = server.address().port + const url = `http://${formatHostForUrl(host)}:${actualPort}${MCP_PATH}` + logger.banner(url) + + resolve({ + url, + kill: () => supervisor.kill(), // immediate SIGKILL — never-orphan backstop + close: () => + new Promise((resolveOuter) => { + closers.resolveClose = resolveOuter + transport.close() + supervisor.stop() // grace -> SIGKILL, never-orphan + server.close(finishClose) + // Force lingering keep-alive/stalled sockets closed so finishClose + // fires promptly (server.close otherwise waits for them indefinitely). + if (typeof server.closeAllConnections === 'function') server.closeAllConnections() + }), + }) + }) + }) +} + +module.exports = { + startMcpFace, + bearerMatches, + DEFAULT_BODY_CAP_BYTES, + MCP_PATH, + BODY_ABORTED, +} diff --git a/thunderbolt-stdio-bridge/src/mcp-server.test.js b/thunderbolt-stdio-bridge/src/mcp-server.test.js new file mode 100644 index 000000000..cc99ee0ea --- /dev/null +++ b/thunderbolt-stdio-bridge/src/mcp-server.test.js @@ -0,0 +1,441 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict' + +const { test, expect, mock } = require('bun:test') +const { EventEmitter } = require('node:events') +const { startMcpFace, bearerMatches, BODY_ABORTED } = require('./mcp-server') + +/** A silent PII-safe logger spy. */ +const makeLogger = () => ({ + info: mock(() => {}), + warn: mock(() => {}), + error: mock(() => {}), + banner: mock(() => {}), +}) + +/** Fake http.Server: EventEmitter with listen/address/close. */ +const makeFakeServer = () => { + const server = new EventEmitter() + server.listening = false + server.listen = mock((port, host, cb) => { + server._port = port === 0 ? 54321 : port + server.listening = true + queueMicrotask(cb) + return server + }) + server.address = () => ({ port: server._port }) + server.close = mock((cb) => { + server.listening = false + if (cb) queueMicrotask(cb) + return server + }) + server.closeAllConnections = mock(() => {}) + return server +} + +/** Fake StreamableHTTPServerTransport. */ +const makeFakeTransport = () => { + const transport = { + onmessage: null, + send: mock(() => Promise.resolve()), + handleRequest: mock(() => Promise.resolve()), + close: mock(() => Promise.resolve()), + } + return transport +} + +/** Fake superviseChild controller capturing wiring + calls. */ +const makeFakeSupervisor = () => { + const calls = { stdin: [], paused: 0, resumed: 0, stopped: 0, killed: 0 } + const supervisor = { + child: { stdin: new EventEmitter() }, + writeStdin: mock((chunk) => { + calls.stdin.push(chunk.toString()) + return true + }), + pauseStdout: mock(() => { + calls.paused += 1 + }), + resumeStdout: mock(() => { + calls.resumed += 1 + }), + stop: mock(() => { + calls.stopped += 1 + }), + kill: mock(() => { + calls.killed += 1 + }), + alive: () => true, + } + return { supervisor, calls } +} + +/** Build the injectable deps + capture the wired supervise hooks. */ +const makeHarness = (overrides = {}) => { + const server = makeFakeServer() + const transport = makeFakeTransport() + const { supervisor, calls } = makeFakeSupervisor() + const hooks = {} + const deps = { + createServer: mock(() => server), + StreamableHTTPServerTransport: class { + constructor() { + return transport + } + }, + superviseChild: mock((opts) => { + hooks.onStdout = opts.onStdout + hooks.onExit = opts.onExit + hooks.onSpawnError = opts.onSpawnError + return supervisor + }), + ...overrides, + } + return { server, transport, supervisor, calls, hooks, deps } +} + +const baseOpts = (logger, deps, extra = {}) => ({ + launch: ['mcp-server'], + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + logger, + deps, + ...extra, +}) + +/** Fake request: an EventEmitter with method/url/headers + replayable body. */ +const makeReq = ({ method = 'POST', url = '/mcp', headers = {}, body } = {}) => { + const req = new EventEmitter() + req.method = method + req.url = url + req.headers = headers + req.destroy = mock(() => {}) + req.resume = mock(() => {}) + req._body = body + return req +} + +/** Fake response capturing status + end. */ +const makeRes = () => { + const res = { + statusCode: null, + headers: {}, + ended: false, + writeHead: mock((status) => { + res.statusCode = status + }), + setHeader: mock((k, v) => { + res.headers[k] = v + }), + end: mock(() => { + res.ended = true + }), + } + return res +} + +/** Emit a request through the server and replay its body chunks, then settle. */ +const fireRequest = async (server, req, res) => { + server.emit('request', req, res) + if (req.method === 'POST') { + // Let the request handler attach its data/end listeners first. + await Promise.resolve() + if (req._body !== undefined) req.emit('data', Buffer.from(req._body)) + req.emit('end') + } + // Drain microtasks so the async handler completes. + await new Promise((r) => setTimeout(r, 0)) +} + +test('binds and resolves url http://127.0.0.1:PORT/mcp, calling logger.banner once', async () => { + const logger = makeLogger() + const { server, deps } = makeHarness() + const face = await startMcpFace(baseOpts(logger, deps)) + expect(face.url).toBe('http://127.0.0.1:54321/mcp') + expect(logger.banner).toHaveBeenCalledTimes(1) + expect(logger.banner.mock.calls[0][0]).toBe('http://127.0.0.1:54321/mcp') + expect(server.listen).toHaveBeenCalled() +}) + +test('bearer set: a request with no Authorization → 401 BEFORE routing/parsing', async () => { + const logger = makeLogger() + const { server, transport, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps, { bearer: 'sekret' })) + const req = makeReq({ headers: {}, body: '{"jsonrpc":"2.0"}' }) + const res = makeRes() + await fireRequest(server, req, res) + expect(res.statusCode).toBe(401) + expect(transport.handleRequest).not.toHaveBeenCalled() +}) + +test('bearer set: an incorrect Authorization → 401', async () => { + const logger = makeLogger() + const { server, transport, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps, { bearer: 'sekret' })) + const req = makeReq({ headers: { authorization: 'Bearer wrong' } }) + const res = makeRes() + await fireRequest(server, req, res) + expect(res.statusCode).toBe(401) + expect(transport.handleRequest).not.toHaveBeenCalled() +}) + +test('bearer set: a correct bearer passes and is dispatched', async () => { + const logger = makeLogger() + const { server, transport, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps, { bearer: 'sekret' })) + const req = makeReq({ headers: { authorization: 'Bearer sekret' }, body: '{"jsonrpc":"2.0","method":"ping"}' }) + const res = makeRes() + await fireRequest(server, req, res) + expect(transport.handleRequest).toHaveBeenCalledTimes(1) +}) + +test('bearerMatches: unequal-length inputs fail without throwing', () => { + expect(() => bearerMatches('a', 'abcdef')).not.toThrow() + expect(bearerMatches('a', 'abcdef')).toBe(false) + expect(bearerMatches(undefined, 'abc')).toBe(false) + expect(bearerMatches('abc', 'abc')).toBe(true) +}) + +test('CORS preflight (OPTIONS) returns 204 and allow-origin per allowlist', async () => { + const logger = makeLogger() + const { server, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps)) + const req = makeReq({ method: 'OPTIONS', headers: { origin: 'http://localhost:5173' } }) + const res = makeRes() + await fireRequest(server, req, res) + expect(res.statusCode).toBe(204) + expect(res.headers['Access-Control-Allow-Origin']).toBe('http://localhost:5173') +}) + +test('--allow-any-origin echoes * for any Origin', async () => { + const logger = makeLogger() + const { server, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps, { allowAnyOrigin: true })) + const req = makeReq({ method: 'OPTIONS', headers: { origin: 'http://evil.com' } }) + const res = makeRes() + await fireRequest(server, req, res) + expect(res.headers['Access-Control-Allow-Origin']).toBe('*') +}) + +test('a malformed JSON POST body → 400 and is not dispatched', async () => { + const logger = makeLogger() + const { server, transport, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps)) + const req = makeReq({ body: 'not json at all' }) + const res = makeRes() + await fireRequest(server, req, res) + expect(res.statusCode).toBe(400) + expect(transport.handleRequest).not.toHaveBeenCalled() +}) + +test('a client that aborts the POST body (error) is dropped: no reply, no throw, no dispatch', async () => { + const logger = makeLogger() + const { server, transport, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps)) + const req = makeReq({ headers: {} }) + const res = makeRes() + // No unhandledRejection escapes: readBody must never reject. + const rejections = [] + const onRejection = (err) => rejections.push(err) + process.on('unhandledRejection', onRejection) + server.emit('request', req, res) + await Promise.resolve() // let the handler attach its listeners + req.emit('error', Object.assign(new Error('aborted'), { code: 'ECONNRESET' })) + await new Promise((r) => setTimeout(r, 0)) + process.removeListener('unhandledRejection', onRejection) + expect(rejections).toHaveLength(0) + expect(res.writeHead).not.toHaveBeenCalled() + expect(res.end).not.toHaveBeenCalled() + expect(transport.handleRequest).not.toHaveBeenCalled() +}) + +test('a client that closes the POST socket before end is dropped: no reply, no hang', async () => { + const logger = makeLogger() + const { server, transport, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps)) + const req = makeReq({ headers: {} }) + const res = makeRes() + server.emit('request', req, res) + await Promise.resolve() + // Premature 'close' (no preceding 'end') => readBody resolves BODY_ABORTED and + // the handler returns; the promise must resolve (otherwise this test hangs). + req.emit('close') + await new Promise((r) => setTimeout(r, 0)) + expect(res.writeHead).not.toHaveBeenCalled() + expect(transport.handleRequest).not.toHaveBeenCalled() +}) + +test('BODY_ABORTED is an exported sentinel distinct from a real body', () => { + expect(typeof BODY_ABORTED).toBe('symbol') +}) + +test('a disallowed Origin is not granted CORS access', async () => { + const logger = makeLogger() + const { server, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps)) + const req = makeReq({ method: 'OPTIONS', headers: { origin: 'http://evil.com' } }) + const res = makeRes() + await fireRequest(server, req, res) + expect(res.headers['Access-Control-Allow-Origin']).toBeUndefined() +}) + +test('body exceeding bodyCapBytes → 413 and is not dispatched', async () => { + const logger = makeLogger() + const { server, transport, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps, { bodyCapBytes: 8 })) + const req = makeReq({ body: '{"a":"aaaaaaaaaaaaaaaa"}' }) + const res = makeRes() + await fireRequest(server, req, res) + expect(res.statusCode).toBe(413) + expect(transport.handleRequest).not.toHaveBeenCalled() +}) + +test('a non-/mcp path → 404', async () => { + const logger = makeLogger() + const { server, transport, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps)) + const req = makeReq({ method: 'GET', url: '/nope' }) + const res = makeRes() + await fireRequest(server, req, res) + expect(res.statusCode).toBe(404) + expect(transport.handleRequest).not.toHaveBeenCalled() +}) + +test('a non-/mcp path is still bearer-gated when bearer set', async () => { + const logger = makeLogger() + const { server, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps, { bearer: 'sekret' })) + const req = makeReq({ method: 'GET', url: '/nope', headers: {} }) + const res = makeRes() + await fireRequest(server, req, res) + expect(res.statusCode).toBe(401) +}) + +test('POST /mcp is dispatched to transport.handleRequest with the parsed body', async () => { + const logger = makeLogger() + const { server, transport, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps)) + const req = makeReq({ body: '{"jsonrpc":"2.0","method":"initialize","id":1}' }) + const res = makeRes() + await fireRequest(server, req, res) + expect(transport.handleRequest).toHaveBeenCalledTimes(1) + const parsed = transport.handleRequest.mock.calls[0][2] + expect(parsed).toEqual({ jsonrpc: '2.0', method: 'initialize', id: 1 }) +}) + +test('HTTP onmessage relays to child stdin as one NDJSON line', async () => { + const logger = makeLogger() + const { transport, calls, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps)) + transport.onmessage({ jsonrpc: '2.0', method: 'ping', id: 7 }) + expect(calls.stdin).toHaveLength(1) + expect(calls.stdin[0]).toBe('{"jsonrpc":"2.0","method":"ping","id":7}\n') +}) + +test('child stdout NDJSON lines are parsed and sent to the transport', async () => { + const logger = makeLogger() + const { transport, hooks, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps)) + hooks.onStdout(Buffer.from('{"jsonrpc":"2.0","result":{},"id":7}\n')) + expect(transport.send).toHaveBeenCalledTimes(1) + expect(transport.send.mock.calls[0][0]).toEqual({ jsonrpc: '2.0', result: {}, id: 7 }) +}) + +test('a malformed child stdout line is dropped + logged by method/id only, never sent', async () => { + const logger = makeLogger() + const { transport, hooks, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps)) + hooks.onStdout(Buffer.from('not json\n')) + expect(transport.send).not.toHaveBeenCalled() + expect(logger.warn).toHaveBeenCalled() +}) + +test('child exit closes the http server + transport and resolves close()', async () => { + const logger = makeLogger() + const { server, transport, hooks, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps)) + hooks.onExit({ code: 0, signal: null }) + expect(transport.close).toHaveBeenCalled() + expect(server.close).toHaveBeenCalled() +}) + +test('child self-exit propagates to onChildExit with the exit info', async () => { + const logger = makeLogger() + const { hooks, deps } = makeHarness() + const onChildExit = mock(() => {}) + await startMcpFace(baseOpts(logger, deps, { onChildExit })) + hooks.onExit({ code: 0, signal: null }) + expect(onChildExit).toHaveBeenCalledTimes(1) + expect(onChildExit).toHaveBeenCalledWith({ code: 0, signal: null }) +}) + +test('child exit also force-closes lingering connections (closeAllConnections)', async () => { + const logger = makeLogger() + const { server, hooks, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps)) + hooks.onExit({ code: 1, signal: null }) + expect(server.closeAllConnections).toHaveBeenCalled() +}) + +test('close() closes transport, stops the child, and closes the server: no orphan', async () => { + const logger = makeLogger() + const { server, transport, calls, deps } = makeHarness() + const face = await startMcpFace(baseOpts(logger, deps)) + await face.close() + expect(transport.close).toHaveBeenCalled() + expect(calls.stopped).toBe(1) + expect(server.close).toHaveBeenCalled() +}) + +test('close() force-closes lingering sockets and resolves deterministically (no hang)', async () => { + const logger = makeLogger() + const { server, deps } = makeHarness() + // finishClose only fires once server.close's callback runs; lingering keep-alive + // sockets would defer it forever without closeAllConnections. + const face = await startMcpFace(baseOpts(logger, deps)) + await face.close() // resolving at all proves finishClose fired + expect(server.close).toHaveBeenCalled() + expect(server.closeAllConnections).toHaveBeenCalled() +}) + +test('the resolved face exposes kill() which immediately SIGKILLs the child', async () => { + const logger = makeLogger() + const { calls, deps } = makeHarness() + const face = await startMcpFace(baseOpts(logger, deps)) + expect(typeof face.kill).toBe('function') + face.kill() + expect(calls.killed).toBe(1) +}) + +test('a spawn ENOENT rejects with an unavailable error and SIGKILLs the child first', async () => { + const logger = makeLogger() + const { server, hooks, calls, deps } = makeHarness() + const promise = startMcpFace(baseOpts(logger, deps)) + // superviseChild is invoked synchronously inside startMcpFace; trigger spawn error. + hooks.onSpawnError(Object.assign(new Error('enoent'), { code: 'ENOENT' })) + await expect(promise).rejects.toMatchObject({ name: 'UnavailableError', code: 'ENOENT' }) + expect(server.close).toHaveBeenCalled() +}) + +test('a bind failure rejects with an unavailable error and SIGKILLs the child first', async () => { + const logger = makeLogger() + // Server whose listen never fires success but emits an error. + const server = new EventEmitter() + server.listen = mock(() => { + queueMicrotask(() => server.emit('error', Object.assign(new Error('in use'), { code: 'EADDRINUSE' }))) + return server + }) + server.address = () => ({ port: 0 }) + server.close = mock(() => {}) + const { calls, deps } = makeHarness({ createServer: () => server }) + await expect(startMcpFace(baseOpts(logger, deps))).rejects.toMatchObject({ + name: 'UnavailableError', + code: 'EADDRINUSE', + }) + expect(calls.killed).toBe(1) +}) diff --git a/thunderbolt-stdio-bridge/src/relay.js b/thunderbolt-stdio-bridge/src/relay.js new file mode 100644 index 000000000..38b163521 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/relay.js @@ -0,0 +1,93 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict' + +/** Thrown when a frame fails to parse as JSON so the face can drop + log it. */ +class MalformedFrameError extends Error { + constructor() { + super('malformed frame') + this.name = 'MalformedFrameError' + } +} + +/** + * Incremental NDJSON line splitter over a byte stream. Buffers partial chunks, + * emits one `onLine` per complete `\n`-terminated line (sans the newline), + * tolerates `\r\n`, skips empty/whitespace-only lines, and never emits a + * partial line. `flush()` emits a trailing unterminated line if present. + * @param {(line: string) => void} onLine + * @returns {{ push(chunk: Buffer|string): void, flush(): void }} + */ +const createNdjsonReader = (onLine) => { + let buffer = '' + + const emit = (raw) => { + const line = raw.replace(/\r$/, '').trim() + if (line.length > 0) onLine(line) + } + + return { + push(chunk) { + buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8') + let newlineIndex = buffer.indexOf('\n') + while (newlineIndex !== -1) { + emit(buffer.slice(0, newlineIndex)) + buffer = buffer.slice(newlineIndex + 1) + newlineIndex = buffer.indexOf('\n') + } + }, + flush() { + if (buffer.length === 0) return + emit(buffer) + buffer = '' + }, + } +} + +/** + * Parse a frame string as JSON, throwing a typed MalformedFrameError on bad + * input so callers can drop the frame and log only its method/id classification. + * @param {string} text + * @returns {unknown} + */ +const parseFrame = (text) => { + try { + return JSON.parse(text) + } catch { + throw new MalformedFrameError() + } +} + +/** + * Map one NDJSON child-stdout line to the WS message payload the app expects: a + * single JSON-RPC object per WS message, no trailing newline. Validates it + * parses as JSON; throws MalformedFrameError otherwise. + * @param {string} line + * @returns {string} + */ +const frameToWs = (line) => { + parseFrame(line) + return line +} + +/** + * Map one inbound WS message (one JSON-RPC object) to the NDJSON line written to + * child stdin — appends exactly one `\n`. Validates JSON; throws + * MalformedFrameError otherwise. + * @param {string} message + * @returns {string} + */ +const wsToFrame = (message) => { + parseFrame(message) + return `${message}\n` +} + +module.exports = { + MalformedFrameError, + createNdjsonReader, + frameToWs, + wsToFrame, + parseFrame, +} diff --git a/thunderbolt-stdio-bridge/src/relay.test.js b/thunderbolt-stdio-bridge/src/relay.test.js new file mode 100644 index 000000000..c004f48f9 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/relay.test.js @@ -0,0 +1,88 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict' + +const { test, expect } = require('bun:test') +const { createNdjsonReader, frameToWs, wsToFrame, MalformedFrameError, parseFrame } = require('./relay') + +const collect = () => { + const lines = [] + const reader = createNdjsonReader((line) => lines.push(line)) + return { lines, reader } +} + +test('a single complete line emits one onLine without the trailing newline', () => { + const { lines, reader } = collect() + reader.push('{"a":1}\n') + expect(lines).toEqual(['{"a":1}']) +}) + +test('a JSON object split across two push() calls emits exactly once when completed', () => { + const { lines, reader } = collect() + reader.push('{"a":') + expect(lines).toEqual([]) + reader.push('1}\n') + expect(lines).toEqual(['{"a":1}']) +}) + +test('multiple newline-separated lines in one chunk emit in order', () => { + const { lines, reader } = collect() + reader.push('{"a":1}\n{"b":2}\n{"c":3}\n') + expect(lines).toEqual(['{"a":1}', '{"b":2}', '{"c":3}']) +}) + +test('\\r\\n endings are normalized to a clean line', () => { + const { lines, reader } = collect() + reader.push('{"a":1}\r\n') + expect(lines).toEqual(['{"a":1}']) +}) + +test('empty / whitespace-only lines are skipped (no emit)', () => { + const { lines, reader } = collect() + reader.push('\n \n{"a":1}\n\n') + expect(lines).toEqual(['{"a":1}']) +}) + +test('flush() emits a trailing unterminated line; flush() on empty buffer emits nothing', () => { + const { lines, reader } = collect() + reader.push('{"a":1}') + expect(lines).toEqual([]) + reader.flush() + expect(lines).toEqual(['{"a":1}']) + reader.flush() + expect(lines).toEqual(['{"a":1}']) +}) + +test('accepts a Buffer chunk', () => { + const { lines, reader } = collect() + reader.push(Buffer.from('{"a":1}\n', 'utf8')) + expect(lines).toEqual(['{"a":1}']) +}) + +test('frameToWs returns the JSON object string with NO trailing newline', () => { + expect(frameToWs('{"a":1}')).toBe('{"a":1}') +}) + +test('wsToFrame appends exactly one trailing newline', () => { + expect(wsToFrame('{"a":1}')).toBe('{"a":1}\n') +}) + +test('frameToWs and wsToFrame throw MalformedFrameError on non-JSON input', () => { + expect(() => frameToWs('not json')).toThrow(MalformedFrameError) + expect(() => wsToFrame('not json')).toThrow(MalformedFrameError) +}) + +test('parseFrame returns the parsed object on valid JSON and throws MalformedFrameError otherwise', () => { + expect(parseFrame('{"method":"x"}')).toEqual({ method: 'x' }) + expect(() => parseFrame('{bad')).toThrow(MalformedFrameError) +}) + +test('round-trip: wsToFrame then createNdjsonReader yields the original object string', () => { + const original = '{"jsonrpc":"2.0","method":"initialize","id":1}' + const { lines, reader } = collect() + reader.push(wsToFrame(original)) + expect(lines).toEqual([original]) + expect(frameToWs(lines[0])).toBe(original) +}) diff --git a/thunderbolt-stdio-bridge/src/server.js b/thunderbolt-stdio-bridge/src/server.js new file mode 100644 index 000000000..82f7517c7 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/server.js @@ -0,0 +1,205 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// The ACP WebSocket face. Binds a ws WebSocketServer on host:port (default +// 127.0.0.1), spawns the child via superviseChild, and pumps +// child.stdout (NDJSON) -> WS and WS -> child.stdin with Origin-gating, +// single-client newest-wins, and pause/resume backpressure. Prints the +// `ws://127.0.0.1:PORT` banner to stderr once listening. + +'use strict' + +const { UnavailableError } = require('./errors') +const { buildOriginAllowlist, classifyMethod, classifyId } = require('./log') +const { createNdjsonReader, frameToWs, wsToFrame } = require('./relay') +const { superviseChild: defaultSuperviseChild } = require('./child') +const { formatHostForUrl } = require('./util') + +/** Pause child stdout once a socket buffers more than this many bytes. */ +const HIGH_WATER = 1 << 20 // 1 MiB +/** Resume child stdout once the socket drains below this. */ +const LOW_WATER = 1 << 18 // 256 KiB +/** Normal WS close code for a superseded/torn-down client. */ +const CLOSE_NORMAL = 1000 + +/** PII-safe classification of a raw frame; never throws and never returns body. */ +const safeClassify = (raw) => { + try { + const frame = JSON.parse(raw) + return { method: classifyMethod(frame), id: classifyId(frame) } + } catch { + return { method: 'unknown', id: 'absent' } + } +} + +/** + * Start the ACP WebSocket face: bind, spawn the child, and bridge NDJSON stdio + * to a single newest-wins WebSocket client with an Origin gate and backpressure. + * + * @param {Object} opts + * @param {string[]} opts.launch - child launch argv. + * @param {string} opts.host + * @param {number} opts.port - 0 => OS-assigned ephemeral. + * @param {string[]} opts.allowOrigins + * @param {boolean} opts.allowAnyOrigin + * @param {Object} opts.logger + * @param {(info: {code: number|null, signal: string|null}) => void} [opts.onChildExit] + * - notified when the child exits so the caller can derive its exit code. + * @param {Object} [opts.deps] - injectable { WebSocketServer, spawn, superviseChild }. + * @returns {Promise<{ url: string, kill(): void, close(): Promise }>} + */ +const startBridge = ({ + launch, + host, + port, + allowOrigins, + allowAnyOrigin, + logger, + onChildExit, + deps = {}, +}) => { + // The real ws dep is resolved lazily so fully-faked unit tests never load it. + const WebSocketServer = deps.WebSocketServer ?? require('ws').WebSocketServer + const superviseChild = deps.superviseChild ?? defaultSuperviseChild + const isOriginAllowed = buildOriginAllowlist({ allowOrigins, allowAnyOrigin }) + + return new Promise((resolve, reject) => { + const closers = { resolveClose: null, settled: false } + let client = null + let supervisor = null + let paused = false + + const sendToClient = (line) => { + if (!client || client.readyState !== client.OPEN) return + const payload = frameToWs(line) + // The send callback fires once this frame has flushed to the socket; use it + // to lift backpressure event-driven (no polling timer). + client.send(payload, () => { + if (paused && client && client.bufferedAmount < LOW_WATER) { + paused = false + supervisor.resumeStdout() + } + }) + if (client.bufferedAmount > HIGH_WATER && !paused) { + paused = true + supervisor.pauseStdout() + } + } + + // child.stdout NDJSON -> WS, dropping malformed lines (logged by method/id). + const reader = createNdjsonReader((line) => { + try { + sendToClient(line) + } catch { + logger.warn('drop-child-frame', safeClassify(line)) + } + }) + + // Origin gate (default-on): ws calls verifyClient on every upgrade BEFORE a + // socket is accepted, so a disallowed Origin is rejected with no socket. + const wss = new WebSocketServer({ + host, + port, + verifyClient: ({ origin }) => isOriginAllowed(origin), + }) + + const finishClose = () => { + if (closers.settled) return + closers.settled = true + if (closers.resolveClose) closers.resolveClose() + } + + supervisor = superviseChild({ + launch, + spawn: deps.spawn, + logger, + onStdout: (chunk) => reader.push(chunk), + onExit: (info) => { + reader.flush() + if (client) client.close(CLOSE_NORMAL) + wss.close(finishClose) + if (onChildExit) onChildExit(info) + }, + onSpawnError: (err) => { + // Spawn ENOENT etc. — tear the server down and surface as unavailable. + wss.close() + if (!closers.settled) reject(new UnavailableError({ code: err.code })) + }, + }) + + wss.on('error', (err) => { + // Bind failures (EADDRINUSE/EACCES) arrive here before 'listening'. + supervisor.kill() // never-orphan + wss.close() + reject(new UnavailableError({ code: err.code })) + }) + + wss.on('connection', (ws) => { + // newest-wins: a new client supersedes the prior one (closed 1000). + if (client && client.readyState === client.OPEN) { + client.close(CLOSE_NORMAL) + } + client = ws + if (paused) { + // The prior client congested and physically paused child.stdout; the new + // client must not inherit a wedged stream. + paused = false + supervisor.resumeStdout() + } + + ws.on('message', (data) => { + const raw = typeof data === 'string' ? data : data.toString('utf8') + + const frame = (() => { + try { + return wsToFrame(raw) + } catch { + return null + } + })() + if (frame === null) { + logger.warn('drop-ws-frame', safeClassify(raw)) + return + } + + // Child stdin backpressure: writeStdin returns false when the pipe is + // full — stop reading WS until the child stdin drains. + if (supervisor.writeStdin(frame) === false) { + ws.pause() + supervisor.child.stdin.once('drain', () => ws.resume()) + } + }) + + ws.on('close', () => { + if (client === ws) client = null + }) + }) + + wss.on('listening', () => { + const actualPort = wss.address().port + const url = `ws://${formatHostForUrl(host)}:${actualPort}` + logger.banner(url) + + resolve({ + url, + kill: () => supervisor.kill(), // immediate SIGKILL — never-orphan backstop + close: () => + new Promise((resolveOuter) => { + // Already torn down (e.g. child exited first): resolve immediately. + if (closers.settled) { + supervisor.stop() // idempotent no-op once the child is gone + resolveOuter() + return + } + closers.resolveClose = resolveOuter + if (client) client.close(CLOSE_NORMAL) + supervisor.stop() // grace -> SIGKILL, never-orphan + wss.close(finishClose) + }), + }) + }) + }) +} + +module.exports = { startBridge, HIGH_WATER, LOW_WATER, CLOSE_NORMAL } diff --git a/thunderbolt-stdio-bridge/src/server.test.js b/thunderbolt-stdio-bridge/src/server.test.js new file mode 100644 index 000000000..0712b9376 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/server.test.js @@ -0,0 +1,483 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +'use strict' + +const { test, expect, mock } = require('bun:test') +const { EventEmitter } = require('node:events') +const { startBridge, HIGH_WATER, LOW_WATER, CLOSE_NORMAL } = require('./server') + +const OPEN = 1 + +/** Fake WebSocketServer: EventEmitter capturing its options, with address/close. */ +const makeFakeWss = () => { + let opts = null + class FakeWss extends EventEmitter { + constructor(o) { + super() + opts = o + this._port = 5123 + } + address() { + return { port: this._port } + } + close(cb) { + this.closed = true + if (cb) cb() + } + } + return { FakeWss, getOpts: () => opts } +} + +/** Fake WS client socket. */ +const makeFakeWs = (overrides = {}) => { + const ws = new EventEmitter() + ws.OPEN = OPEN + ws.readyState = OPEN + ws.bufferedAmount = 0 + ws.send = mock((data, cb) => { + if (cb) cb() + }) + ws.close = mock((code) => { + ws.lastCloseCode = code + ws.readyState = 3 + }) + ws.pause = mock(() => {}) + ws.resume = mock(() => {}) + return Object.assign(ws, overrides) +} + +/** Fake supervisor captured so the test can drive child stdout/exit. */ +const makeFakeSupervisor = () => { + const stdin = new EventEmitter() + stdin.destroyed = false + const sup = { + child: { stdin }, + onStdout: null, + onExit: null, + onSpawnError: null, + writeStdin: mock(() => true), + pauseStdout: mock(() => {}), + resumeStdout: mock(() => {}), + stop: mock(() => {}), + kill: mock(() => {}), + alive: () => true, + } + const factory = (args) => { + sup.onStdout = args.onStdout + sup.onExit = args.onExit + sup.onSpawnError = args.onSpawnError + sup.launch = args.launch + return sup + } + return { sup, factory } +} + +const noopLogger = { error: () => {}, warn: mock(() => {}), info: () => {}, banner: mock(() => {}) } + +const start = (over = {}) => { + const { FakeWss, getOpts } = makeFakeWss() + const { sup, factory } = makeFakeSupervisor() + const logger = { + error: () => {}, + info: () => {}, + warn: mock(() => {}), + banner: mock(() => {}), + } + const promise = startBridge({ + launch: ['node', 'agent.js'], + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + logger, + deps: { WebSocketServer: FakeWss, superviseChild: factory }, + ...over, + }) + // The promise resolves on 'listening'; expose the wss to drive it. + return { promise, getOpts, sup, logger, FakeWss } +} + +test('binds on 127.0.0.1 and resolves url ws://127.0.0.1:PORT, calling logger.banner once', async () => { + let wssRef + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o) { + super(o) + wssRef = this + } + } + const { factory } = makeFakeSupervisor() + const logger = { error: () => {}, info: () => {}, warn: () => {}, banner: mock(() => {}) } + const p = startBridge({ + launch: ['x'], + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + logger, + deps: { WebSocketServer: Capture, superviseChild: factory }, + }) + wssRef.emit('listening') + const face = await p + expect(face.url).toBe('ws://127.0.0.1:5123') + expect(logger.banner).toHaveBeenCalledTimes(1) + expect(logger.banner).toHaveBeenCalledWith('ws://127.0.0.1:5123') +}) + +test('a listen EADDRINUSE rejects with an unavailable error and SIGKILLs the child first', async () => { + let wssRef + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o) { + super(o) + wssRef = this + } + } + const { sup, factory } = makeFakeSupervisor() + const p = startBridge({ + launch: ['x'], + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + logger: noopLogger, + deps: { WebSocketServer: Capture, superviseChild: factory }, + }) + wssRef.emit('error', Object.assign(new Error('in use'), { code: 'EADDRINUSE' })) + await expect(p).rejects.toMatchObject({ name: 'UnavailableError', code: 'EADDRINUSE' }) + expect(sup.kill).toHaveBeenCalledTimes(1) +}) + +test('Origin gate rejects a disallowed Origin and accepts a loopback/undefined Origin', async () => { + const { getOpts, promise } = start() + const verifyClient = getOpts().verifyClient + expect(verifyClient({ origin: 'http://evil.com' })).toBe(false) + expect(verifyClient({ origin: 'http://localhost:3000' })).toBe(true) + expect(verifyClient({ origin: undefined })).toBe(true) + // resolve the dangling promise + getOpts() // noop; clean up by emitting listening through a fresh handle below + await Promise.resolve() +}) + +test('--allow-any-origin accepts any Origin (gate disabled)', async () => { + const { getOpts } = start({ allowAnyOrigin: true }) + expect(getOpts().verifyClient({ origin: 'http://evil.com' })).toBe(true) +}) + +test('newest-wins: a second connection closes the first with 1000 and becomes sole pump', async () => { + let wssRef + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o) { + super(o) + wssRef = this + } + } + const { sup, factory } = makeFakeSupervisor() + const p = startBridge({ + launch: ['x'], + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + logger: noopLogger, + deps: { WebSocketServer: Capture, superviseChild: factory }, + }) + wssRef.emit('listening') + await p + const first = makeFakeWs() + const second = makeFakeWs() + wssRef.emit('connection', first) + wssRef.emit('connection', second) + expect(first.close).toHaveBeenCalledWith(CLOSE_NORMAL) + // The newest socket pumps to the child. + second.emit('message', '{"jsonrpc":"2.0","method":"ping","id":1}') + expect(sup.writeStdin).toHaveBeenCalledTimes(1) +}) + +test('supersession physically resumes child stdout the prior client had paused (no wedge)', async () => { + let wssRef + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o) { + super(o) + wssRef = this + } + } + const { sup, factory } = makeFakeSupervisor() + const p = startBridge({ + launch: ['x'], + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + logger: noopLogger, + deps: { WebSocketServer: Capture, superviseChild: factory }, + }) + wssRef.emit('listening') + await p + // First client congests: its send keeps bufferedAmount above HIGH_WATER so the + // pause branch fires and never resumes (we hold the send callbacks pending). + const first = makeFakeWs() + first.bufferedAmount = HIGH_WATER + 1 + first.send = mock(() => {}) // callback never invoked -> stays paused + wssRef.emit('connection', first) + sup.onStdout(Buffer.from('{"a":1}\n')) + expect(sup.pauseStdout).toHaveBeenCalledTimes(1) + expect(sup.resumeStdout).not.toHaveBeenCalled() + // A new client supersedes the wedged one; it must physically resume stdout. + const second = makeFakeWs() + wssRef.emit('connection', second) + expect(first.close).toHaveBeenCalledWith(CLOSE_NORMAL) + expect(sup.resumeStdout).toHaveBeenCalledTimes(1) + // child output now flows to the new client (not wedged behind the old pause). + sup.onStdout(Buffer.from('{"b":2}\n')) + expect(second.send).toHaveBeenCalledTimes(1) + expect(second.send.mock.calls[0][0]).toBe('{"b":2}') +}) + +test('child stdout NDJSON line is frameToWs-d and sent to the live client (no trailing newline)', async () => { + let wssRef + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o) { + super(o) + wssRef = this + } + } + const { sup, factory } = makeFakeSupervisor() + const p = startBridge({ + launch: ['x'], + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + logger: noopLogger, + deps: { WebSocketServer: Capture, superviseChild: factory }, + }) + wssRef.emit('listening') + await p + const ws = makeFakeWs() + wssRef.emit('connection', ws) + sup.onStdout(Buffer.from('{"jsonrpc":"2.0","result":1,"id":2}\n')) + expect(ws.send).toHaveBeenCalledTimes(1) + expect(ws.send.mock.calls[0][0]).toBe('{"jsonrpc":"2.0","result":1,"id":2}') +}) + +test('inbound WS message is wsToFrame-d and written to child stdin with a trailing newline', async () => { + let wssRef + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o) { + super(o) + wssRef = this + } + } + const { sup, factory } = makeFakeSupervisor() + const p = startBridge({ + launch: ['x'], + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + logger: noopLogger, + deps: { WebSocketServer: Capture, superviseChild: factory }, + }) + wssRef.emit('listening') + await p + const ws = makeFakeWs() + wssRef.emit('connection', ws) + ws.emit('message', '{"jsonrpc":"2.0","method":"m","id":3}') + expect(sup.writeStdin).toHaveBeenCalledWith('{"jsonrpc":"2.0","method":"m","id":3}\n') +}) + +test('backpressure: high ws.bufferedAmount pauses child stdout; drain resumes it', async () => { + let wssRef + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o) { + super(o) + wssRef = this + } + } + const { sup, factory } = makeFakeSupervisor() + const p = startBridge({ + launch: ['x'], + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + logger: noopLogger, + deps: { WebSocketServer: Capture, superviseChild: factory }, + }) + wssRef.emit('listening') + await p + // send callback deferred so we control when 'flush' (resume check) fires. + const pending = [] + const ws = makeFakeWs() + ws.bufferedAmount = HIGH_WATER + 1 + ws.send = mock((_data, cb) => pending.push(cb)) + wssRef.emit('connection', ws) + sup.onStdout(Buffer.from('{"a":1}\n')) + expect(sup.pauseStdout).toHaveBeenCalledTimes(1) + // drain below low-water then fire the send callback => resume. + ws.bufferedAmount = LOW_WATER - 1 + pending.forEach((cb) => cb()) + expect(sup.resumeStdout).toHaveBeenCalledTimes(1) +}) + +test('backpressure: writeStdin returning false pauses WS reading until drain', async () => { + let wssRef + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o) { + super(o) + wssRef = this + } + } + const { sup, factory } = makeFakeSupervisor() + sup.writeStdin = mock(() => false) + const p = startBridge({ + launch: ['x'], + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + logger: noopLogger, + deps: { WebSocketServer: Capture, superviseChild: factory }, + }) + wssRef.emit('listening') + await p + const ws = makeFakeWs() + wssRef.emit('connection', ws) + ws.emit('message', '{"jsonrpc":"2.0","method":"m","id":1}') + expect(ws.pause).toHaveBeenCalledTimes(1) + sup.child.stdin.emit('drain') + expect(ws.resume).toHaveBeenCalledTimes(1) +}) + +test('a malformed frame (either direction) is dropped and logged by method/id only, not fatal', async () => { + let wssRef + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o) { + super(o) + wssRef = this + } + } + const { sup, factory } = makeFakeSupervisor() + const logger = { error: () => {}, info: () => {}, warn: mock(() => {}), banner: () => {} } + const p = startBridge({ + launch: ['x'], + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + logger, + deps: { WebSocketServer: Capture, superviseChild: factory }, + }) + wssRef.emit('listening') + await p + const ws = makeFakeWs() + wssRef.emit('connection', ws) + // malformed inbound: not JSON + ws.emit('message', 'not json {{{') + expect(sup.writeStdin).not.toHaveBeenCalled() + // malformed child line dropped (no send) + sup.onStdout(Buffer.from('still not json{{{\n')) + expect(ws.send).not.toHaveBeenCalled() + // logged, and never the raw body + const loggedBodies = logger.warn.mock.calls.flat().map((a) => JSON.stringify(a)) + expect(loggedBodies.some((s) => s.includes('not json'))).toBe(false) + expect(logger.warn).toHaveBeenCalled() +}) + +test('child exit closes the server + client(1000) and resolves close()', async () => { + let wssRef + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o) { + super(o) + wssRef = this + } + } + const { sup, factory } = makeFakeSupervisor() + const onChildExit = mock(() => {}) + const p = startBridge({ + launch: ['x'], + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + logger: noopLogger, + onChildExit, + deps: { WebSocketServer: Capture, superviseChild: factory }, + }) + wssRef.emit('listening') + const face = await p + const ws = makeFakeWs() + wssRef.emit('connection', ws) + sup.onExit({ code: 0, signal: null }) + expect(ws.close).toHaveBeenCalledWith(CLOSE_NORMAL) + expect(wssRef.closed).toBe(true) + expect(onChildExit).toHaveBeenCalledWith({ code: 0, signal: null }) + // close() resolves even after the child already exited. + await face.close() +}) + +test('the resolved face exposes kill() which immediately SIGKILLs the child', async () => { + let wssRef + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o) { + super(o) + wssRef = this + } + } + const { sup, factory } = makeFakeSupervisor() + const p = startBridge({ + launch: ['x'], + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + logger: noopLogger, + deps: { WebSocketServer: Capture, superviseChild: factory }, + }) + wssRef.emit('listening') + const face = await p + expect(typeof face.kill).toBe('function') + face.kill() + expect(sup.kill).toHaveBeenCalledTimes(1) +}) + +test('close() closes sockets, closes the server, and stops the child (grace->SIGKILL)', async () => { + let wssRef + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o) { + super(o) + wssRef = this + } + } + const { sup, factory } = makeFakeSupervisor() + const p = startBridge({ + launch: ['x'], + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + logger: noopLogger, + deps: { WebSocketServer: Capture, superviseChild: factory }, + }) + wssRef.emit('listening') + const face = await p + const ws = makeFakeWs() + wssRef.emit('connection', ws) + await face.close() + expect(ws.close).toHaveBeenCalledWith(CLOSE_NORMAL) + expect(sup.stop).toHaveBeenCalledTimes(1) + expect(wssRef.closed).toBe(true) +}) diff --git a/thunderbolt-stdio-bridge/src/tunnel.js b/thunderbolt-stdio-bridge/src/tunnel.js new file mode 100644 index 000000000..66af61adc --- /dev/null +++ b/thunderbolt-stdio-bridge/src/tunnel.js @@ -0,0 +1,112 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Stand up a cloudflared quick tunnel in front of the local MCP face (MCP only) +// and MINT a mandatory bearer. Spawns `cloudflared tunnel --url `, +// parses the assigned *.trycloudflare.com URL from cloudflared's stderr, and +// generates a high-entropy bearer. The bearer is printed to STDERR ONLY and +// never embedded in the public URL or any query string — mcp-server.js compares +// it constant-time on every request. + +'use strict' + +const { spawn: defaultSpawn } = require('node:child_process') +const { randomBytes: defaultRandomBytes } = require('node:crypto') +const { UnavailableError } = require('./errors') + +/** Entropy of the minted bearer: 32 bytes => 256 bits. */ +const BEARER_BYTES = 32 +/** Grace window before SIGKILLing cloudflared on close(). */ +const GRACE_MS = 2000 +/** Give cloudflared this long to print its public URL before declaring it unavailable. */ +const URL_TIMEOUT_MS = 30000 +/** Matches the quick-tunnel URL cloudflared prints to its stderr. */ +const TRYCLOUDFLARE_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i + +/** + * Start a cloudflared quick tunnel in front of `localUrl` and mint a mandatory + * bearer. Resolves once cloudflared prints its public URL; rejects with an + * UnavailableError if cloudflared is missing (ENOENT) or never prints a URL in + * time. The bearer is logged to stderr and returned — never put in the URL. + * + * @param {Object} opts + * @param {string} opts.localUrl - the local MCP face URL to tunnel to. + * @param {Object} opts.logger - PII-safe logger. + * @param {Function} [opts.spawn] - injectable child_process.spawn. + * @param {(n: number) => Buffer} [opts.randomBytes] - injectable crypto.randomBytes. + * @param {number} [opts.urlTimeoutMs] - how long to wait for the public URL. + * @returns {Promise<{ publicUrl: string, bearer: string, close(): Promise }>} + */ +const startTunnel = ({ + localUrl, + logger, + spawn = defaultSpawn, + randomBytes = defaultRandomBytes, + urlTimeoutMs = URL_TIMEOUT_MS, +}) => { + // Mandatory bearer: there is no unauthenticated tunnel path. + const bearer = randomBytes(BEARER_BYTES).toString('base64url') + + return new Promise((resolve, reject) => { + const settled = { done: false } + const child = spawn('cloudflared', ['tunnel', '--url', localUrl]) + + let graceTimer = null + const clearGrace = () => { + if (graceTimer) { + clearTimeout(graceTimer) + graceTimer = null + } + } + + const close = () => + new Promise((resolveClose) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolveClose() + return + } + child.once('exit', () => { + clearGrace() + resolveClose() + }) + child.kill('SIGTERM') + graceTimer = setTimeout(() => child.kill('SIGKILL'), GRACE_MS) + if (typeof graceTimer.unref === 'function') graceTimer.unref() + }) + + const urlTimer = setTimeout(() => { + if (settled.done) return + settled.done = true + child.kill('SIGKILL') // never-orphan + reject(new UnavailableError({ message: 'cloudflared did not report a tunnel URL in time' })) + }, urlTimeoutMs) + if (typeof urlTimer.unref === 'function') urlTimer.unref() + + child.on('error', (err) => { + if (settled.done) return + settled.done = true + clearTimeout(urlTimer) + // ENOENT => cloudflared not installed; surface as unavailable (cli -> 69). + reject(new UnavailableError({ code: err.code, message: 'cloudflared not found' })) + }) + + // cloudflared prints diagnostics — including the assigned URL — to stderr. + child.stderr.on('data', (chunk) => { + if (settled.done) return + const match = TRYCLOUDFLARE_RE.exec(chunk.toString('utf8')) + if (!match) return + settled.done = true + clearTimeout(urlTimer) + const publicUrl = match[0] + // Bearer to STDERR ONLY — never in the URL or a query param. The bearer is + // a secret, so it rides in the event string (printed verbatim) rather than + // a scalar field (which the allowlist would drop anyway). + logger.banner(publicUrl) + logger.warn(`tunnel-bearer Authorization: Bearer ${bearer}`) + resolve({ publicUrl, bearer, close }) + }) + }) +} + +module.exports = { startTunnel, BEARER_BYTES, URL_TIMEOUT_MS } diff --git a/thunderbolt-stdio-bridge/src/tunnel.test.js b/thunderbolt-stdio-bridge/src/tunnel.test.js new file mode 100644 index 000000000..87e6b87e6 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/tunnel.test.js @@ -0,0 +1,182 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict' + +const { test, expect, mock } = require('bun:test') +const { EventEmitter } = require('node:events') +const { startTunnel, BEARER_BYTES } = require('./tunnel') + +/** Capturing logger that records every event/text line written. */ +const makeLogger = () => { + const lines = [] + return { + lines, + info: mock((event) => lines.push(`info ${event}`)), + warn: mock((event) => lines.push(`warn ${event}`)), + error: mock((event) => lines.push(`error ${event}`)), + banner: mock((url) => lines.push(`banner ${url}`)), + } +} + +/** Fake cloudflared child: EventEmitter with a stderr stream + kill capture. */ +const makeChild = () => { + const child = new EventEmitter() + child.stderr = new EventEmitter() + child.exitCode = null + child.signalCode = null + child.signals = [] + child.kill = mock((sig) => { + child.signals.push(sig) + return true + }) + return child +} + +/** Deterministic randomBytes returning a fixed-byte buffer of length n. */ +const fixedRandomBytes = (fill) => (n) => Buffer.alloc(n, fill) + +test('spawns cloudflared with `tunnel --url `', async () => { + const logger = makeLogger() + const child = makeChild() + const spawn = mock(() => child) + const promise = startTunnel({ + localUrl: 'http://127.0.0.1:5000/mcp', + logger, + spawn, + randomBytes: fixedRandomBytes(1), + }) + child.stderr.emit('data', Buffer.from('INF | https://happy-cloud-1.trycloudflare.com |\n')) + await promise + expect(spawn).toHaveBeenCalledWith('cloudflared', ['tunnel', '--url', 'http://127.0.0.1:5000/mcp']) +}) + +test('parses the https://*.trycloudflare.com URL from stderr and resolves publicUrl', async () => { + const logger = makeLogger() + const child = makeChild() + const promise = startTunnel({ + localUrl: 'http://127.0.0.1:5000/mcp', + logger, + spawn: () => child, + randomBytes: fixedRandomBytes(2), + }) + child.stderr.emit('data', Buffer.from('2024 INF Your quick Tunnel: https://abc-def-ghi.trycloudflare.com\n')) + const { publicUrl } = await promise + expect(publicUrl).toBe('https://abc-def-ghi.trycloudflare.com') +}) + +test('generates a high-entropy bearer (>=256 bits) and returns it', async () => { + const logger = makeLogger() + const child = makeChild() + let requestedBytes = 0 + const randomBytes = (n) => { + requestedBytes = n + return Buffer.alloc(n, 3) + } + const promise = startTunnel({ localUrl: 'http://127.0.0.1:5000/mcp', logger, spawn: () => child, randomBytes }) + child.stderr.emit('data', Buffer.from('https://x.trycloudflare.com\n')) + const { bearer } = await promise + expect(requestedBytes).toBe(BEARER_BYTES) + expect(BEARER_BYTES * 8).toBeGreaterThanOrEqual(256) + expect(bearer).toBe(Buffer.alloc(BEARER_BYTES, 3).toString('base64url')) +}) + +test('the bearer is logged to stderr and NEVER appears in publicUrl or any query string', async () => { + const logger = makeLogger() + const child = makeChild() + const promise = startTunnel({ + localUrl: 'http://127.0.0.1:5000/mcp', + logger, + spawn: () => child, + randomBytes: fixedRandomBytes(7), + }) + child.stderr.emit('data', Buffer.from('https://secret-tunnel.trycloudflare.com\n')) + const { publicUrl, bearer } = await promise + expect(publicUrl).not.toContain(bearer) + expect(publicUrl).not.toContain('?') + // The bearer must have been written to the (stderr) logger. + const bearerLogged = logger.warn.mock.calls.some((c) => c.some((arg) => String(arg).includes(bearer))) + expect(bearerLogged).toBe(true) +}) + +test('cloudflared ENOENT rejects with an unavailable error (→69) and a "not found" message', async () => { + const logger = makeLogger() + const child = makeChild() + const promise = startTunnel({ + localUrl: 'http://127.0.0.1:5000/mcp', + logger, + spawn: () => child, + randomBytes: fixedRandomBytes(1), + }) + child.emit('error', Object.assign(new Error('spawn cloudflared ENOENT'), { code: 'ENOENT' })) + await expect(promise).rejects.toMatchObject({ name: 'UnavailableError', code: 'ENOENT' }) + await expect(promise).rejects.toThrow(/not found/) +}) + +test('a timeout with no URL printed rejects with an unavailable error (→69) and SIGKILLs', async () => { + const logger = makeLogger() + const child = makeChild() + const promise = startTunnel({ + localUrl: 'http://127.0.0.1:5000/mcp', + logger, + spawn: () => child, + randomBytes: fixedRandomBytes(1), + urlTimeoutMs: 5, + }) + await expect(promise).rejects.toMatchObject({ name: 'UnavailableError' }) + expect(child.signals).toContain('SIGKILL') +}) + +test('close() SIGTERMs then SIGKILLs cloudflared (grace window): no orphan', async () => { + const logger = makeLogger() + const child = makeChild() + const promise = startTunnel({ + localUrl: 'http://127.0.0.1:5000/mcp', + logger, + spawn: () => child, + randomBytes: fixedRandomBytes(1), + }) + child.stderr.emit('data', Buffer.from('https://x.trycloudflare.com\n')) + const { close } = await promise + const closing = close() + expect(child.signals).toContain('SIGTERM') + // The child reports exit within the grace window → no SIGKILL needed. + child.exitCode = 0 + child.emit('exit', 0, null) + await closing +}) + +test('close() is a no-op when cloudflared already exited', async () => { + const logger = makeLogger() + const child = makeChild() + const promise = startTunnel({ + localUrl: 'http://127.0.0.1:5000/mcp', + logger, + spawn: () => child, + randomBytes: fixedRandomBytes(1), + }) + child.stderr.emit('data', Buffer.from('https://x.trycloudflare.com\n')) + const { close } = await promise + child.exitCode = 0 + await close() + expect(child.signals).not.toContain('SIGTERM') +}) + +test('two runs produce distinct bearers (randomBytes-driven)', async () => { + const logger = makeLogger() + const run = async (fill) => { + const child = makeChild() + const promise = startTunnel({ + localUrl: 'http://127.0.0.1:5000/mcp', + logger, + spawn: () => child, + randomBytes: fixedRandomBytes(fill), + }) + child.stderr.emit('data', Buffer.from('https://x.trycloudflare.com\n')) + return (await promise).bearer + } + const a = await run(1) + const b = await run(2) + expect(a).not.toBe(b) +}) diff --git a/thunderbolt-stdio-bridge/src/util.js b/thunderbolt-stdio-bridge/src/util.js new file mode 100644 index 000000000..f599d2f2a --- /dev/null +++ b/thunderbolt-stdio-bridge/src/util.js @@ -0,0 +1,79 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict' + +const { UsageError } = require('./errors') + +/** + * Coerce a CLI port value to an integer in [0, 65535]. `undefined`/empty → 0 + * (OS-assigned ephemeral port). Throws UsageError on NaN or out-of-range. + * @param {string|number|undefined} raw + * @returns {number} + */ +const resolvePort = (raw) => { + if (raw === undefined || raw === '') return 0 + const str = String(raw) + if (!/^\d+$/.test(str)) throw new UsageError(`--port must be an integer in 0..65535, got "${str}"`) + const port = Number(str) + if (port > 65535) throw new UsageError(`--port must be an integer in 0..65535, got "${str}"`) + return port +} + +/** + * Render a host for embedding in a URL — wraps bare IPv6 literals in brackets, + * passes through IPv4 / hostnames / already-bracketed literals unchanged. + * @param {string} host + * @returns {string} + */ +const formatHostForUrl = (host) => { + if (host.startsWith('[') && host.endsWith(']')) return host + if (host.includes(':')) return `[${host}]` + return host +} + +/** + * True iff `host` is a loopback literal: 127.0.0.0/8, ::1 (bracketed or not), + * or localhost (case-insensitive). Single source of truth for the Origin + * allowlist and the insecure-flag warnings; mirrors the app's isLoopbackHost. + * @param {string} host + * @returns {boolean} + */ +const isLoopbackHost = (host) => { + const stripped = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host + const lower = stripped.toLowerCase() + if (lower === 'localhost' || lower === '::1') return true + const octets = lower.split('.') + if (octets.length !== 4) return false + return octets[0] === '127' && octets.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255) +} + +/** + * Build the list of loud warning lines to emit before binding. Empty array when + * the config is safe. Builds messages only — printing is the caller's job, so + * this stays pure and testable. + * @param {{ host: string, allowAnyOrigin: boolean, tunnel: boolean }} opts + * @returns {string[]} + */ +const insecureFlagWarnings = ({ host, allowAnyOrigin, tunnel }) => { + const warnings = [] + if (allowAnyOrigin && !isLoopbackHost(host)) { + warnings.push( + `DANGER: --allow-any-origin on non-loopback host "${host}" disables the Origin gate and exposes the face to the network — any site can drive your local server.`, + ) + } else if (allowAnyOrigin) { + warnings.push('WARNING: --allow-any-origin disables the Origin gate — any browser Origin may connect.') + } + if (tunnel) { + warnings.push('WARNING: --tunnel exposes the MCP face publicly via cloudflared (protected by a mandatory bearer).') + } + return warnings +} + +module.exports = { + resolvePort, + formatHostForUrl, + isLoopbackHost, + insecureFlagWarnings, +} diff --git a/thunderbolt-stdio-bridge/src/util.test.js b/thunderbolt-stdio-bridge/src/util.test.js new file mode 100644 index 000000000..194a3e066 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/util.test.js @@ -0,0 +1,79 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict' + +const { test, expect } = require('bun:test') +const { resolvePort, formatHostForUrl, isLoopbackHost, insecureFlagWarnings } = require('./util') +const { UsageError } = require('./errors') + +test('resolvePort: undefined→0, ""→0, "0"→0, "8080"→8080', () => { + expect(resolvePort(undefined)).toBe(0) + expect(resolvePort('')).toBe(0) + expect(resolvePort('0')).toBe(0) + expect(resolvePort('8080')).toBe(8080) + expect(resolvePort(8080)).toBe(8080) +}) + +test('resolvePort: out-of-range / non-integer each throw UsageError', () => { + expect(() => resolvePort('70000')).toThrow(UsageError) + expect(() => resolvePort('abc')).toThrow(UsageError) + expect(() => resolvePort('-1')).toThrow(UsageError) + expect(() => resolvePort('3000.5')).toThrow(UsageError) +}) + +test('formatHostForUrl wraps ::1 in brackets and is idempotent on [::1]', () => { + expect(formatHostForUrl('::1')).toBe('[::1]') + expect(formatHostForUrl('[::1]')).toBe('[::1]') +}) + +test('formatHostForUrl passes 127.0.0.1 and hostnames through unchanged', () => { + expect(formatHostForUrl('127.0.0.1')).toBe('127.0.0.1') + expect(formatHostForUrl('localhost')).toBe('localhost') + expect(formatHostForUrl('example.com')).toBe('example.com') +}) + +test('isLoopbackHost true for 127.0.0.1, 127.0.0.2, ::1, [::1], LOCALHOST', () => { + expect(isLoopbackHost('127.0.0.1')).toBe(true) + expect(isLoopbackHost('127.0.0.2')).toBe(true) + expect(isLoopbackHost('::1')).toBe(true) + expect(isLoopbackHost('[::1]')).toBe(true) + expect(isLoopbackHost('LOCALHOST')).toBe(true) +}) + +test('isLoopbackHost false for 0.0.0.0, 192.168.1.5, example.com', () => { + expect(isLoopbackHost('0.0.0.0')).toBe(false) + expect(isLoopbackHost('192.168.1.5')).toBe(false) + expect(isLoopbackHost('example.com')).toBe(false) + expect(isLoopbackHost('127.0.0.256')).toBe(false) +}) + +test('insecureFlagWarnings empty for a safe loopback config', () => { + expect(insecureFlagWarnings({ host: '127.0.0.1', allowAnyOrigin: false, tunnel: false })).toEqual([]) +}) + +test('insecureFlagWarnings has one line for allowAnyOrigin on a loopback host', () => { + const warnings = insecureFlagWarnings({ host: '127.0.0.1', allowAnyOrigin: true, tunnel: false }) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain('--allow-any-origin') +}) + +test('insecureFlagWarnings has an EXTRA loud DANGER line for allowAnyOrigin + non-loopback host', () => { + const warnings = insecureFlagWarnings({ host: '0.0.0.0', allowAnyOrigin: true, tunnel: false }) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain('DANGER') + expect(warnings[0]).toContain('0.0.0.0') +}) + +test('insecureFlagWarnings notes public exposure when tunnel:true', () => { + const warnings = insecureFlagWarnings({ host: '127.0.0.1', allowAnyOrigin: false, tunnel: true }) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain('--tunnel') +}) + +test('insecureFlagWarnings returns strings and performs no I/O', () => { + const warnings = insecureFlagWarnings({ host: '0.0.0.0', allowAnyOrigin: true, tunnel: true }) + expect(warnings).toHaveLength(2) + expect(warnings.every((w) => typeof w === 'string')).toBe(true) +}) From eca4e20d559a0555d778aeac37a87d8d79fa6204 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 25 Jun 2026 09:02:12 -0300 Subject: [PATCH 02/45] ci(THU-601): build, smoke-test and release stdio-bridge on tag --- .github/workflows/stdio-bridge-release.yml | 96 ++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 .github/workflows/stdio-bridge-release.yml diff --git a/.github/workflows/stdio-bridge-release.yml b/.github/workflows/stdio-bridge-release.yml new file mode 100644 index 000000000..bbc000bcd --- /dev/null +++ b/.github/workflows/stdio-bridge-release.yml @@ -0,0 +1,96 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# Build, smoke-test, and (on a tag) publish the thunderbolt-stdio-bridge CLI. +# +# On every push/PR touching thunderbolt-stdio-bridge/** we bundle bridge.cjs and +# prove all three entry points work: --help exits 0, the ACP face prints its +# `listening` banner, the MCP face prints its `mcp-listening` banner. On a +# `stdio-bridge-v*` tag we additionally attach bridge.cjs to the tag's GitHub +# Release, which install.sh fetches via releases/download/stdio-bridge-v/. + +name: stdio-bridge release + +on: + push: + branches: [main] + paths: ['thunderbolt-stdio-bridge/**'] + tags: ['stdio-bridge-v*'] + pull_request: + paths: ['thunderbolt-stdio-bridge/**'] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +defaults: + run: + working-directory: thunderbolt-stdio-bridge + +jobs: + build: + name: Build & smoke-test bridge.cjs + runs-on: ubuntu-24.04 + # The tag job needs write to publish the release; everything else stays read. + permissions: + contents: ${{ startsWith(github.ref, 'refs/tags/stdio-bridge-v') && 'write' || 'read' }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - run: bun install --frozen-lockfile + - run: bun run build + + # bridge.cjs parses and --help exits 0 (no child, no framing at risk). + - name: Smoke --help + run: | + node dist/bridge.cjs --help | grep -q "thunderbolt-stdio-bridge" + node dist/bridge.cjs --version + + # ACP face: bind the WS face over a trivial `cat` child and confirm the + # readiness banner reaches stderr (token: `listening`). Capture stderr to a + # file, poll until the banner lands, then kill the still-running bridge. + - name: Smoke ACP (listening) + run: | + node dist/bridge.cjs --mode acp --json -- cat 2>acp.log & + pid=$! + for _ in $(seq 1 50); do + grep -q '"event":"listening"' acp.log && break + sleep 0.2 + done + kill "$pid" 2>/dev/null || true + grep -q '"event":"listening"' acp.log + grep -q 'ws://127.0.0.1:' acp.log + + # MCP face: bridge the bundled server-everything stdio server and confirm + # the readiness banner reaches stderr (token: `mcp-listening`). + - name: Smoke MCP (mcp-listening) + run: | + node dist/bridge.cjs --mode mcp --json -- \ + node node_modules/@modelcontextprotocol/server-everything/dist/index.js stdio 2>mcp.log & + pid=$! + for _ in $(seq 1 50); do + grep -q '"event":"mcp-listening"' mcp.log && break + sleep 0.2 + done + kill "$pid" 2>/dev/null || true + grep -q '"event":"mcp-listening"' mcp.log + grep -q 'http://127.0.0.1:' mcp.log + + - name: Publish bridge.cjs to the tag's GitHub Release + if: startsWith(github.ref, 'refs/tags/stdio-bridge-v') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release create "${GITHUB_REF_NAME}" dist/bridge.cjs --title "${GITHUB_REF_NAME}" --generate-notes From 1fead79c31b8275b69ce55c0e8e974303a634f13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 25 Jun 2026 09:02:17 -0300 Subject: [PATCH 03/45] feat(THU-601): connect local ACP/MCP agents via stdio-bridge from the catalog --- e2e/acp-agents-catalog.spec.ts | 39 ++++++ src/acp/transports/index.test.ts | 46 +++++++ src/acp/transports/index.ts | 10 ++ src/acp/transports/is-loopback.test.ts | 67 ++++++++++ src/acp/transports/is-loopback.ts | 42 +++++++ .../agents/add-custom-agent-dialog.test.tsx | 45 +++++++ .../agents/add-custom-agent-dialog.tsx | 17 +++ .../settings/agents/agent-catalog-card.tsx | 15 ++- .../agents/agent-catalog-view.test.tsx | 7 +- .../agents/bridge-connect-dialog.test.tsx | 100 +++++++++++++++ .../settings/agents/bridge-connect-dialog.tsx | 119 ++++++++++++++++++ .../settings/agents/copyable-command.test.tsx | 43 +++++++ .../settings/agents/copyable-command.tsx | 44 +++++++ src/lib/agent-bridge-command.test.ts | 79 ++++++++++++ src/lib/agent-bridge-command.ts | 66 ++++++++++ src/lib/mcp-transport.test.ts | 52 +++++++- src/lib/mcp-transport.ts | 54 +++++--- 17 files changed, 825 insertions(+), 20 deletions(-) create mode 100644 src/acp/transports/is-loopback.test.ts create mode 100644 src/acp/transports/is-loopback.ts create mode 100644 src/components/settings/agents/bridge-connect-dialog.test.tsx create mode 100644 src/components/settings/agents/bridge-connect-dialog.tsx create mode 100644 src/components/settings/agents/copyable-command.test.tsx create mode 100644 src/components/settings/agents/copyable-command.tsx create mode 100644 src/lib/agent-bridge-command.test.ts create mode 100644 src/lib/agent-bridge-command.ts diff --git a/e2e/acp-agents-catalog.spec.ts b/e2e/acp-agents-catalog.spec.ts index 960849e9d..be56925f3 100644 --- a/e2e/acp-agents-catalog.spec.ts +++ b/e2e/acp-agents-catalog.spec.ts @@ -41,4 +41,43 @@ test.describe('Agents catalog', () => { // the snapshot fallback covers it. expect(errors).toEqual([]) }) + + test('connect-via-bridge opens the bridge dialog with the run command for an npx agent', async ({ page }) => { + const errors = collectPageErrors(page) + await loginViaOidc(page) + await page.goto('/settings/agents') + + await expect(page.getByTestId('agent-catalog-card-gemini')).toBeVisible({ timeout: 10_000 }) + + // Open the bridge dialog from the gemini card (npx distribution). + await page.getByTestId('agent-catalog-connect-gemini').click() + await expect(page.getByTestId('bridge-connect-dialog')).toBeVisible() + + // The composed bridge run command is shown for the user to copy. + await expect(page.getByText('npx thunderbolt-stdio-bridge --mode acp --', { exact: false })).toBeVisible() + // The install one-liner is present too. + await expect(page.getByText('curl -fsSL', { exact: false })).toBeVisible() + + // Close the dialog. + await page.getByRole('button', { name: /done/i }).click() + await expect(page.getByTestId('bridge-connect-dialog')).toHaveCount(0) + + expect(errors).toEqual([]) + }) + + test('connect-via-bridge shows the binary fallback for a binary-only agent', async ({ page }) => { + const errors = collectPageErrors(page) + await loginViaOidc(page) + await page.goto('/settings/agents') + + await expect(page.getByTestId('agent-catalog-card-goose')).toBeVisible({ timeout: 10_000 }) + + // Goose ships as a platform binary — the dialog renders the fallback, no run command. + await page.getByTestId('agent-catalog-connect-goose').click() + await expect(page.getByTestId('bridge-connect-dialog')).toBeVisible() + await expect(page.getByText(/ships as a platform binary/i)).toBeVisible() + await expect(page.getByText('npx thunderbolt-stdio-bridge --mode acp --', { exact: false })).toHaveCount(0) + + expect(errors).toEqual([]) + }) }) diff --git a/src/acp/transports/index.test.ts b/src/acp/transports/index.test.ts index dca112df4..815465f4b 100644 --- a/src/acp/transports/index.test.ts +++ b/src/acp/transports/index.test.ts @@ -223,4 +223,50 @@ describe('openTransport — agent-type routing', () => { transport.close() }) + + it('remote-acp to a loopback bridge URL on Web connects natively (no proxy tunnel)', async () => { + // The stdio-bridge prints `ws://127.0.0.1:PORT`. Even on Web (where the proxy + // toggle would normally force the universal-proxy path) a loopback target must + // connect directly — the proxy hard-rejects loopback/private hosts (4003). + const transport = await openTransport({ + url: 'ws://127.0.0.1:8080', + transport: 'websocket', + agentType: 'remote-acp', + signal: new AbortController().signal, + isStandalone: () => false, + readProxyEnabled: () => null, + backoffMs: () => 1, + httpClient: stubHttpClient, + getAuthToken: () => 'should-not-be-used', + }) + + expect(FakeBrowserSocket.instances).toHaveLength(1) + const socket = FakeBrowserSocket.instances[0] + // Connects to the loopback URL itself, not the cloud `/proxy/ws` endpoint. + expect(socket.url).toBe('ws://127.0.0.1:8080') + // No carrier / bearer / target subprotocols — a bare native connect. + expect(socket.protocols).toHaveLength(0) + + transport.close() + }) + + it('remote-acp to an IPv6 loopback bridge URL connects natively', async () => { + const transport = await openTransport({ + url: 'ws://[::1]:8080', + transport: 'websocket', + agentType: 'remote-acp', + signal: new AbortController().signal, + isStandalone: () => false, + readProxyEnabled: () => null, + backoffMs: () => 1, + httpClient: stubHttpClient, + }) + + expect(FakeBrowserSocket.instances).toHaveLength(1) + const socket = FakeBrowserSocket.instances[0] + expect(socket.url).toBe('ws://[::1]:8080') + expect(socket.protocols).toHaveLength(0) + + transport.close() + }) }) diff --git a/src/acp/transports/index.ts b/src/acp/transports/index.ts index 782c22b12..d6b58c2a2 100644 --- a/src/acp/transports/index.ts +++ b/src/acp/transports/index.ts @@ -36,6 +36,7 @@ import { useLocalSettingsStore } from '@/stores/local-settings-store' import type { AgentType } from '@shared/acp-types' import { encodeWsBearer, wsBearerSubprotocolPrefix, wsCarrierSubprotocol } from '@shared/ws-bearer' import type { AcpTransport } from '../types' +import { isLoopbackUrl } from './is-loopback' import { openWebSocketTransport, type WebSocketFactory, type WebSocketLike } from './websocket' export type OpenTransportInputs = { @@ -108,6 +109,15 @@ const resolveWebSocketFactory = (inputs: OpenTransportInputs): WebSocketFactory if (inputs.agentType === 'managed-acp') { return resolveManagedAcpFactory(inputs) } + // A loopback target is the local stdio-bridge (it prints `ws://127.0.0.1:PORT`). + // Connect with a plain native WebSocket regardless of platform or proxy toggle: + // the universal proxy hard-rejects loopback/private hosts (4003), and the bridge + // is a trusted same-machine process needing no bearer. This carve-out precedes + // the standalone/proxy decision so it applies on Web too (where the toggle would + // otherwise force the proxy path). + if (isLoopbackUrl(inputs.url)) { + return nativeWebSocketFactory + } if (isStandaloneTransport(inputs.isStandalone, inputs.readProxyEnabled)) { return nativeWebSocketFactory } diff --git a/src/acp/transports/is-loopback.test.ts b/src/acp/transports/is-loopback.test.ts new file mode 100644 index 000000000..30255aa27 --- /dev/null +++ b/src/acp/transports/is-loopback.test.ts @@ -0,0 +1,67 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, expect, it } from 'bun:test' +import { isLoopbackHost, isLoopbackUrl } from './is-loopback' + +describe('isLoopbackHost', () => { + it('classifies the four loopback hostnames', () => { + expect(isLoopbackHost('127.0.0.1')).toBe(true) + expect(isLoopbackHost('::1')).toBe(true) + expect(isLoopbackHost('localhost')).toBe(true) + expect(isLoopbackHost('0.0.0.0')).toBe(true) + }) + + it('is case-insensitive', () => { + expect(isLoopbackHost('LOCALHOST')).toBe(true) + expect(isLoopbackHost('LocalHost')).toBe(true) + }) + + it('accepts a bracketed IPv6 literal (the form URL.hostname yields)', () => { + expect(isLoopbackHost('[::1]')).toBe(true) + }) + + it('rejects non-loopback hosts', () => { + expect(isLoopbackHost('agent.example.com')).toBe(false) + expect(isLoopbackHost('10.0.0.1')).toBe(false) + expect(isLoopbackHost('192.168.1.10')).toBe(false) + expect(isLoopbackHost('::2')).toBe(false) + expect(isLoopbackHost('')).toBe(false) + }) + + it('does not treat a name merely containing "localhost" as loopback', () => { + expect(isLoopbackHost('localhost.evil.com')).toBe(false) + expect(isLoopbackHost('notlocalhost')).toBe(false) + }) +}) + +describe('isLoopbackUrl', () => { + it('classifies the bridge ACP/MCP shorthand the bridge prints', () => { + // Exactly the STDERR shapes: `ws://127.0.0.1:PORT` (ACP), `http://127.0.0.1:PORT/mcp` (MCP). + expect(isLoopbackUrl('ws://127.0.0.1:8080')).toBe(true) + expect(isLoopbackUrl('http://127.0.0.1:9000/mcp')).toBe(true) + }) + + it('canonicalizes shorthand: ports, paths, and casing do not matter', () => { + expect(isLoopbackUrl('http://localhost:3000/mcp')).toBe(true) + expect(isLoopbackUrl('ws://0.0.0.0:1234')).toBe(true) + expect(isLoopbackUrl('http://LOCALHOST/x')).toBe(true) + }) + + it('classifies IPv6 loopback whether bracketed in the URL or not', () => { + expect(isLoopbackUrl('ws://[::1]:8080')).toBe(true) + expect(isLoopbackUrl('http://[::1]/mcp')).toBe(true) + }) + + it('rejects remote endpoints', () => { + expect(isLoopbackUrl('wss://agent.example.com/acp')).toBe(false) + expect(isLoopbackUrl('https://mcp.example.com/server')).toBe(false) + expect(isLoopbackUrl('ws://10.0.0.1:8080')).toBe(false) + }) + + it('treats a malformed URL as not loopback', () => { + expect(isLoopbackUrl('not a url')).toBe(false) + expect(isLoopbackUrl('')).toBe(false) + }) +}) diff --git a/src/acp/transports/is-loopback.ts b/src/acp/transports/is-loopback.ts new file mode 100644 index 000000000..a73a41c3f --- /dev/null +++ b/src/acp/transports/is-loopback.ts @@ -0,0 +1,42 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Loopback classification for the stdio-bridge connect flow. + * + * The bridge binds to `127.0.0.1` and prints `ws://127.0.0.1:PORT` (ACP) or + * `http://127.0.0.1:PORT/mcp` (MCP) on its STDERR. The app connects to that + * local endpoint *directly* — never through the universal proxy, which forbids + * loopback/private targets (4003) and would strip the credential. These helpers + * are the single source of truth for "is this a local bridge endpoint?", read by + * both the ACP WebSocket factory and the MCP fetch selector. + */ + +/** Hostnames that resolve to the local machine, in canonical (bracket-free, + * lowercase) form. `new URL().hostname` brackets IPv6 literals (`[::1]`), so + * `isLoopbackHost` strips those brackets before comparing against `::1`. */ +const loopbackHosts = new Set(['127.0.0.1', '::1', 'localhost', '0.0.0.0']) + +/** + * True when `host` names the local machine. Accepts a bare hostname (no + * scheme/port) and tolerates an IPv6 literal in either bracketed (`[::1]`, the + * form `URL.hostname` yields) or bare (`::1`) form. Case-insensitive. + */ +export const isLoopbackHost = (host: string): boolean => { + const normalized = host.toLowerCase().replace(/^\[(.+)\]$/, '$1') + return loopbackHosts.has(normalized) +} + +/** + * True when `url` points at a loopback host. Parses via `new URL()` so shorthand + * like `ws://127.0.0.1:8080` is canonicalized (IPv6 brackets stripped, host + * lowercased) before classification. A malformed URL is not loopback. + */ +export const isLoopbackUrl = (url: string): boolean => { + try { + return isLoopbackHost(new URL(url).hostname) + } catch { + return false + } +} diff --git a/src/components/settings/agents/add-custom-agent-dialog.test.tsx b/src/components/settings/agents/add-custom-agent-dialog.test.tsx index 2fbbd7259..3294a6247 100644 --- a/src/components/settings/agents/add-custom-agent-dialog.test.tsx +++ b/src/components/settings/agents/add-custom-agent-dialog.test.tsx @@ -297,6 +297,51 @@ describe('AddCustomAgentDialog — connection status', () => { }) }) +describe('AddCustomAgentDialog — local bridge hint', () => { + const notIos = () => false + + const renderDialog = () => + render( + {}} + onSubmit={async () => {}} + isIos={notIos} + testAcpConnection={async () => ({ success: true })} + />, + ) + + it('shows the stdio-bridge hint by default (no URL entered)', () => { + renderDialog() + expect(screen.getByText(/thunderbolt-stdio-bridge/i)).toBeInTheDocument() + expect(screen.queryByTestId('agent-url-loopback-hint')).not.toBeInTheDocument() + }) + + it('switches to the loopback "connects directly" hint for a 127.0.0.1 URL', () => { + renderDialog() + fireEvent.change(screen.getByLabelText(/url/i), { target: { value: 'ws://127.0.0.1:8080' } }) + expect(screen.getByTestId('agent-url-loopback-hint')).toHaveTextContent(/connects directly/i) + // The generic bridge suggestion is replaced once a loopback URL is present. + expect(screen.queryByText(/Running an agent locally/i)).not.toBeInTheDocument() + }) + + it('treats localhost and ::1 as loopback targets', () => { + renderDialog() + fireEvent.change(screen.getByLabelText(/url/i), { target: { value: 'ws://localhost:9000' } }) + expect(screen.getByTestId('agent-url-loopback-hint')).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText(/url/i), { target: { value: 'ws://[::1]:9000' } }) + expect(screen.getByTestId('agent-url-loopback-hint')).toBeInTheDocument() + }) + + it('keeps the generic hint for a non-loopback wss:// URL', () => { + renderDialog() + fireEvent.change(screen.getByLabelText(/url/i), { target: { value: 'wss://agent.example.com/ws' } }) + expect(screen.queryByTestId('agent-url-loopback-hint')).not.toBeInTheDocument() + expect(screen.getByText(/Running an agent locally/i)).toBeInTheDocument() + }) +}) + describe('AddCustomAgentDialog — edit mode', () => { const notIos = () => false diff --git a/src/components/settings/agents/add-custom-agent-dialog.tsx b/src/components/settings/agents/add-custom-agent-dialog.tsx index 59c294e53..bffc2b645 100644 --- a/src/components/settings/agents/add-custom-agent-dialog.tsx +++ b/src/components/settings/agents/add-custom-agent-dialog.tsx @@ -16,6 +16,7 @@ import { import { Dialog } from '@/components/ui/dialog' import { StatusCard } from '@/components/ui/status-card' import { getPlatform, isTauri } from '@/lib/platform' +import { isLoopbackUrl } from '@/acp/transports/is-loopback' import { testAcpConnection as defaultTestAcpConnection } from '@/acp' import type { Agent } from '@/types/acp' @@ -173,6 +174,9 @@ export const AddCustomAgentDialog = ({ const trimmedUrl = state.url.trim() const trimmedDescription = state.description.trim() const validation = validateAgentUrl(trimmedUrl, isIos) + // A loopback URL is a local stdio-bridge endpoint — reassure the user it + // connects directly (no cloud proxy). Derived during render from the field. + const isLoopbackTarget = trimmedUrl.length > 0 && isLoopbackUrl(trimmedUrl) // Surface an invalid-URL error at render time (once the field is non-empty) // so the user sees why Test Connection is unavailable and submit stays gated. const urlError = trimmedUrl.length > 0 && 'error' in validation ? validation.error : null @@ -254,6 +258,19 @@ export const AddCustomAgentDialog = ({

WebSocket endpoint for the remote ACP agent

+ {isLoopbackTarget ? ( +

+ Local bridge address — connects directly on this machine (no proxy). +

+ ) : ( +

+ Running an agent locally? Use the thunderbolt-stdio-bridge and paste + its ws://127.0.0.1:PORT address. +

+ )}
diff --git a/src/components/settings/agents/agent-catalog-card.tsx b/src/components/settings/agents/agent-catalog-card.tsx index 145f9aaec..f5d8dc1d9 100644 --- a/src/components/settings/agents/agent-catalog-card.tsx +++ b/src/components/settings/agents/agent-catalog-card.tsx @@ -2,12 +2,13 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Code2, ExternalLink, Terminal } from 'lucide-react' +import { Code2, ExternalLink, Plug, Terminal } from 'lucide-react' import { useState } from 'react' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader } from '@/components/ui/card' import { distributionLabel, primaryDistributionKind } from '@/lib/agent-registry-filter' import type { RegistryEntry } from '@/types/registry' +import { BridgeConnectDialog } from './bridge-connect-dialog' type AgentCatalogCardProps = { entry: RegistryEntry @@ -18,6 +19,7 @@ type AgentCatalogCardProps = { * action — these CLIs run on the user's own machine, not inside Thunderbolt. */ export const AgentCatalogCard = ({ entry }: AgentCatalogCardProps) => { const [iconFailed, setIconFailed] = useState(false) + const [bridgeOpen, setBridgeOpen] = useState(false) const distributionKind = primaryDistributionKind(entry) const websiteUrl = entry.website ?? entry.repository @@ -56,6 +58,16 @@ export const AgentCatalogCard = ({ entry }: AgentCatalogCardProps) => {

{entry.description}

{metadata}

+ {websiteUrl && (
+ ) } diff --git a/src/components/settings/agents/agent-catalog-view.test.tsx b/src/components/settings/agents/agent-catalog-view.test.tsx index 8905aaeb0..c5e558d28 100644 --- a/src/components/settings/agents/agent-catalog-view.test.tsx +++ b/src/components/settings/agents/agent-catalog-view.test.tsx @@ -127,12 +127,15 @@ describe('AgentCatalogView', () => { expect(header?.querySelector('svg')).toBeInTheDocument() }) - it('exposes only link-out actions per card, never an install action', () => { + it('exposes link-outs plus a connect-via-bridge action, never an install action', () => { renderCatalog([entry({ id: 'goose', name: 'goose' })]) const card = screen.getByTestId('agent-catalog-card-goose') + // Link-outs to the agent's site/repo remain. expect(card.querySelectorAll('a').length).toBeGreaterThan(0) - expect(card.querySelector('button')).not.toBeInTheDocument() + // The only button on the card opens the bridge connect dialog — agents run on + // the user's machine, so there is still no install action and no form submit. + expect(card.querySelector('[data-testid="agent-catalog-connect-goose"]')).toBeInTheDocument() expect(card.querySelector('button[type="submit"]')).not.toBeInTheDocument() }) diff --git a/src/components/settings/agents/bridge-connect-dialog.test.tsx b/src/components/settings/agents/bridge-connect-dialog.test.tsx new file mode 100644 index 000000000..e1017b68a --- /dev/null +++ b/src/components/settings/agents/bridge-connect-dialog.test.tsx @@ -0,0 +1,100 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import '@testing-library/jest-dom' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, mock } from 'bun:test' +import type { RegistryDistribution, RegistryEntry } from '@/types/registry' +import { BridgeConnectDialog } from './bridge-connect-dialog' + +const writeTextMock = mock((_text: string) => Promise.resolve()) +Object.defineProperty(navigator, 'clipboard', { + value: { writeText: writeTextMock }, + configurable: true, +}) + +afterEach(() => { + cleanup() + writeTextMock.mockClear() +}) + +const entryWith = (distribution: RegistryDistribution, overrides: Partial = {}): RegistryEntry => ({ + id: 'gemini', + name: 'Gemini', + version: '0.46.0', + description: 'Google Gemini CLI', + authors: ['Google'], + license: 'Apache-2.0', + website: 'https://example.com/gemini', + repository: 'https://github.com/example/gemini', + distribution, + ...overrides, +}) + +const npxEntry = entryWith({ npx: { package: '@google/gemini-cli@0.46.0', args: ['--acp'] } }) +const binaryEntry = entryWith( + { binary: { 'darwin-aarch64': { cmd: './goose', args: ['acp'] } } }, + { id: 'goose', name: 'Goose' }, +) + +describe('BridgeConnectDialog — npx agent', () => { + it('renders the three connect steps and the bridge command', () => { + render( {}} />) + + expect(screen.getByText(/connect gemini via bridge/i)).toBeInTheDocument() + // Install command and the bridge run command both appear. + expect(screen.getByText(/curl -fsSL/)).toBeInTheDocument() + expect( + screen.getByText('npx thunderbolt-stdio-bridge --mode acp -- npx @google/gemini-cli@0.46.0 --acp'), + ).toBeInTheDocument() + // Step 3 directs the user to Add custom agent. + expect(screen.getByText(/add custom agent/i)).toBeInTheDocument() + }) + + it('copies the bridge run command to the clipboard', async () => { + render( {}} />) + + await act(async () => { + fireEvent.click(screen.getByTestId('copyable-command-copy-run')) + }) + + expect(writeTextMock).toHaveBeenCalledWith( + 'npx thunderbolt-stdio-bridge --mode acp -- npx @google/gemini-cli@0.46.0 --acp', + ) + }) + + it('copies the install command to the clipboard', async () => { + render( {}} />) + + await act(async () => { + fireEvent.click(screen.getByTestId('copyable-command-copy-install')) + }) + + expect(writeTextMock).toHaveBeenCalledTimes(1) + expect(writeTextMock.mock.calls[0][0].startsWith('curl -fsSL')).toBe(true) + }) + + it('invokes onOpenChange(false) when Done is clicked', () => { + const onOpenChange = mock(() => {}) + render() + + fireEvent.click(screen.getByRole('button', { name: /done/i })) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) +}) + +describe('BridgeConnectDialog — binary agent', () => { + it('renders the binary fallback instead of the run command', () => { + render( {}} />) + + // No bridge run command for a binary-only agent. + expect(screen.queryByTestId('copyable-command-copy-run')).not.toBeInTheDocument() + expect(screen.getByText(/ships as a platform binary/i)).toBeInTheDocument() + // Points the user at the agent's own instructions. + expect(screen.getByRole('link', { name: /agent instructions/i })).toHaveAttribute( + 'href', + 'https://example.com/gemini', + ) + }) +}) diff --git a/src/components/settings/agents/bridge-connect-dialog.tsx b/src/components/settings/agents/bridge-connect-dialog.tsx new file mode 100644 index 000000000..d12d07d72 --- /dev/null +++ b/src/components/settings/agents/bridge-connect-dialog.tsx @@ -0,0 +1,119 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Code2, ExternalLink } from 'lucide-react' +import type { ReactNode } from 'react' +import { Button } from '@/components/ui/button' +import { Dialog } from '@/components/ui/dialog' +import { + ResponsiveModalContentComposable, + ResponsiveModalDescription, + ResponsiveModalHeader, + ResponsiveModalTitle, +} from '@/components/ui/responsive-modal' +import { composeBridgeCommand, composeInstallCommand } from '@/lib/agent-bridge-command' +import type { RegistryEntry } from '@/types/registry' +import { CopyableCommand } from './copyable-command' + +type BridgeConnectDialogProps = { + entry: RegistryEntry + open: boolean + onOpenChange: (open: boolean) => void +} + +type StepProps = { + index: number + title: string + children: ReactNode +} + +/** A numbered step with a title and body. */ +const Step = ({ index, title, children }: StepProps) => ( +
+ + {index} + +
+

{title}

+ {children} +
+
+) + +/** Body for binary-only agents: no portable launch command, so we point the + * user at the agent's own docs to install and run it, then connect manually. */ +const BinaryFallback = ({ entry }: { entry: RegistryEntry }) => { + const docsUrl = entry.website ?? entry.repository + return ( +
+ ) +} + +/** + * Walks the user through connecting a catalogue agent via the local + * `thunderbolt-stdio-bridge`: install the bridge, run it wrapping the agent's + * CLI, then add the loopback URL it prints as a custom agent. Binary-only agents + * have no composable launch, so the dialog renders a fallback that points at the + * agent's own docs instead. + */ +export const BridgeConnectDialog = ({ entry, open, onOpenChange }: BridgeConnectDialogProps) => { + const bridgeCommand = composeBridgeCommand(entry) + + return ( + + + + Connect {entry.name} via bridge + + {entry.name} runs on your machine. The bridge exposes it to Thunderbolt over a local WebSocket. + + + {bridgeCommand ? ( +
+ + + + +

+ It prints a ws://127.0.0.1:PORT{' '} + address and stays running. +

+ +
+ +

+ Use "Add custom agent" and paste the printed address as the URL. +

+
+
+ ) : ( +
+ +
+ )} +
+ +
+
+
+ ) +} diff --git a/src/components/settings/agents/copyable-command.test.tsx b/src/components/settings/agents/copyable-command.test.tsx new file mode 100644 index 000000000..4d769baf2 --- /dev/null +++ b/src/components/settings/agents/copyable-command.test.tsx @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import '@testing-library/jest-dom' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, mock } from 'bun:test' +import { CopyableCommand } from './copyable-command' + +const writeTextMock = mock(() => Promise.resolve()) +Object.defineProperty(navigator, 'clipboard', { + value: { writeText: writeTextMock }, + configurable: true, +}) + +afterEach(() => { + cleanup() + writeTextMock.mockClear() +}) + +describe('CopyableCommand', () => { + it('renders the command text', () => { + render() + expect(screen.getByText('npx thunderbolt-stdio-bridge --help')).toBeInTheDocument() + }) + + it('copies the command and flips the button label to Copied', async () => { + render() + + const button = screen.getByRole('button', { name: /copy command/i }) + await act(async () => { + fireEvent.click(button) + }) + + expect(writeTextMock).toHaveBeenCalledWith('echo hi') + expect(screen.getByRole('button', { name: /copied/i })).toBeInTheDocument() + }) + + it('suffixes the copy button testid when testId is provided', () => { + render() + expect(screen.getByTestId('copyable-command-copy-install')).toBeInTheDocument() + }) +}) diff --git a/src/components/settings/agents/copyable-command.tsx b/src/components/settings/agents/copyable-command.tsx new file mode 100644 index 000000000..0421c6d97 --- /dev/null +++ b/src/components/settings/agents/copyable-command.tsx @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Check, Copy } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' + +type CopyableCommandProps = { + /** The shell command to display and copy. */ + command: string + /** Stable suffix for the copy button's testid, e.g. `install`. */ + testId?: string +} + +/** A monospaced code block with a copy-to-clipboard button. Used by the bridge + * connect dialog to surface the install / run commands for the user to paste + * into a terminal. The command wraps so long lines stay readable. */ +export const CopyableCommand = ({ command, testId }: CopyableCommandProps) => { + const { copy, isCopied } = useCopyToClipboard() + + return ( +
+ + {command} + + +
+ ) +} diff --git a/src/lib/agent-bridge-command.test.ts b/src/lib/agent-bridge-command.test.ts new file mode 100644 index 000000000..1ebcb22a3 --- /dev/null +++ b/src/lib/agent-bridge-command.test.ts @@ -0,0 +1,79 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, expect, it } from 'bun:test' +import type { RegistryDistribution, RegistryEntry } from '@/types/registry' +import { composeBridgeCommand, composeInstallCommand, composeLaunchCommand } from './agent-bridge-command' + +const entryWith = (distribution: RegistryDistribution): RegistryEntry => ({ + id: 'test', + name: 'Test Agent', + version: '1.0.0', + description: '', + authors: [], + license: 'MIT', + distribution, +}) + +describe('composeLaunchCommand', () => { + it('builds an npx launch with the package and args', () => { + const entry = entryWith({ npx: { package: '@google/gemini-cli@0.46.0', args: ['--acp'] } }) + expect(composeLaunchCommand(entry)).toBe('npx @google/gemini-cli@0.46.0 --acp') + }) + + it('builds an npx launch with no args (package only)', () => { + const entry = entryWith({ npx: { package: '@agentclientprotocol/claude-agent-acp@0.44.0' } }) + expect(composeLaunchCommand(entry)).toBe('npx @agentclientprotocol/claude-agent-acp@0.44.0') + }) + + it('builds a uvx launch when only uvx is present', () => { + const entry = entryWith({ uvx: { package: 'fast-agent', args: ['acp'] } }) + expect(composeLaunchCommand(entry)).toBe('uvx fast-agent acp') + }) + + it('prefers npx over uvx when both are present', () => { + const entry = entryWith({ + npx: { package: 'node-pkg' }, + uvx: { package: 'py-pkg' }, + }) + expect(composeLaunchCommand(entry)).toBe('npx node-pkg') + }) + + it('returns null for a binary-only distribution', () => { + const entry = entryWith({ binary: { 'darwin-aarch64': { cmd: './goose', args: ['acp'] } } }) + expect(composeLaunchCommand(entry)).toBeNull() + }) + + it('returns null for an empty distribution', () => { + expect(composeLaunchCommand(entryWith({}))).toBeNull() + }) +}) + +describe('composeBridgeCommand', () => { + it('wraps an npx launch in the bridge command (--mode acp)', () => { + const entry = entryWith({ npx: { package: '@google/gemini-cli@0.46.0', args: ['--acp'] } }) + expect(composeBridgeCommand(entry)).toBe( + 'npx thunderbolt-stdio-bridge --mode acp -- npx @google/gemini-cli@0.46.0 --acp', + ) + }) + + it('wraps a uvx launch in the bridge command', () => { + const entry = entryWith({ uvx: { package: 'fast-agent', args: ['acp'] } }) + expect(composeBridgeCommand(entry)).toBe('npx thunderbolt-stdio-bridge --mode acp -- uvx fast-agent acp') + }) + + it('returns null for a binary-only distribution (UI points at the agent site/repo)', () => { + const entry = entryWith({ binary: { 'linux-x86_64': { cmd: './goose' } } }) + expect(composeBridgeCommand(entry)).toBeNull() + }) +}) + +describe('composeInstallCommand', () => { + it('returns the curl | bash one-liner for the bridge installer', () => { + const command = composeInstallCommand() + expect(command).toContain('curl -fsSL') + expect(command).toContain('thunderbolt-stdio-bridge/install.sh') + expect(command.endsWith('| bash')).toBe(true) + }) +}) diff --git a/src/lib/agent-bridge-command.ts b/src/lib/agent-bridge-command.ts new file mode 100644 index 000000000..205594622 --- /dev/null +++ b/src/lib/agent-bridge-command.ts @@ -0,0 +1,66 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Shell-command composers for the stdio-bridge connect flow. + * + * A catalogue agent is a local CLI (npx / uvx / binary). To reach it from the + * app the user runs `thunderbolt-stdio-bridge`, which spawns the agent's CLI and + * exposes it over a loopback WebSocket (`ws://127.0.0.1:PORT`). These helpers + * build the three copyable commands the connect dialog walks the user through: + * + * 1. install the bridge (`composeInstallCommand`) + * 2. run the bridge wrapping the agent (`composeBridgeCommand`) + * 3. (the launch fragment alone, `composeLaunchCommand`, for display/tests) + * + * Binary-distributed agents have no portable one-line launch, so + * `composeBridgeCommand` returns `null` for them and the UI points the user at + * the agent's own site/repo instead. + */ + +import type { RegistryEntry } from '@/types/registry' + +/** The command name the app's `install.sh` installs onto PATH. */ +const bridgeBin = 'thunderbolt-stdio-bridge' + +/** Canonical one-line installer (curl | bash) — matches + * `thunderbolt-stdio-bridge/install.sh`'s documented invocation. The bridge + * drops onto the npm global bin as a node-shebang script. */ +const installCommand = + 'curl -fsSL https://raw.githubusercontent.com/thunderbird/thunderbolt/main/thunderbolt-stdio-bridge/install.sh | bash' + +/** + * The shell fragment that launches the agent's own CLI, e.g. + * `npx @google/gemini-cli@0.46.0 --acp` or `uvx fast-agent ...`. Returns `null` + * for binary distributions (no portable runner) — the UI falls back to the + * agent's site/repo. Prefers npx over uvx, matching `primaryDistributionKind`. + */ +export const composeLaunchCommand = (entry: RegistryEntry): string | null => { + const npx = entry.distribution.npx + if (npx) { + return ['npx', npx.package, ...(npx.args ?? [])].join(' ') + } + const uvx = entry.distribution.uvx + if (uvx) { + return ['uvx', uvx.package, ...(uvx.args ?? [])].join(' ') + } + return null +} + +/** The curl | bash command that installs the bridge onto the user's PATH. */ +export const composeInstallCommand = (): string => installCommand + +/** + * The full bridge command for an agent: + * `npx thunderbolt-stdio-bridge --mode acp -- `. Returns `null` when the + * agent only ships a binary distribution (no composable launch fragment), so the + * dialog can render its binary fallback instead. + */ +export const composeBridgeCommand = (entry: RegistryEntry): string | null => { + const launch = composeLaunchCommand(entry) + if (!launch) { + return null + } + return `npx ${bridgeBin} --mode acp -- ${launch}` +} diff --git a/src/lib/mcp-transport.test.ts b/src/lib/mcp-transport.test.ts index 632c836ce..429603954 100644 --- a/src/lib/mcp-transport.test.ts +++ b/src/lib/mcp-transport.test.ts @@ -2,10 +2,10 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { describe, expect, it } from 'bun:test' +import { describe, expect, it, mock } from 'bun:test' import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' -import { buildMcpHeaders, createMcpTransport } from './mcp-transport' +import { buildMcpHeaders, createMcpTransport, resolveMcpFetch } from './mcp-transport' const url = 'https://mcp.example.com/server' const cloudUrl = 'https://cloud.example.com' @@ -53,6 +53,54 @@ describe('createMcpTransport', () => { }) }) +describe('resolveMcpFetch', () => { + const bridgeUrl = 'http://127.0.0.1:9000/mcp' + + it('uses the native fetch directly for a loopback bridge URL (no proxy rewrite)', async () => { + const native = mock(async () => new Response('ok')) + const fetchFn = resolveMcpFetch(bridgeUrl, cloudUrl, native as unknown as typeof fetch) + + await fetchFn(bridgeUrl, { method: 'POST' }) + + expect(native).toHaveBeenCalledTimes(1) + // The bridge URL is passed through untouched — not rewritten to `${cloudUrl}/v1/proxy`. + expect(native).toHaveBeenCalledWith(bridgeUrl, { method: 'POST' }) + }) + + it('classifies an IPv6 loopback bridge URL as native', async () => { + const native = mock(async () => new Response('ok')) + const fetchFn = resolveMcpFetch('http://[::1]:9000/mcp', cloudUrl, native as unknown as typeof fetch) + + await fetchFn('http://[::1]:9000/mcp') + + expect(native).toHaveBeenCalledTimes(1) + }) + + it('routes a remote URL through the universal proxy (rewrites target to /proxy)', async () => { + // The native seam must NOT be called for a remote target — the proxy fetch + // owns that hop. We assert via globalThis.fetch capturing the rewritten request. + const captured: Request[] = [] + const originalFetch = globalThis.fetch + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + captured.push(input as Request) + return new Response('ok') + }) as unknown as typeof fetch + const native = mock(async () => new Response('native')) + + try { + const fetchFn = resolveMcpFetch(url, cloudUrl, native as unknown as typeof fetch) + await fetchFn(url, { method: 'POST' }) + } finally { + globalThis.fetch = originalFetch + } + + expect(native).not.toHaveBeenCalled() + expect(captured).toHaveLength(1) + // createProxyFetch appends `/proxy` to the cloud base it's handed. + expect(captured[0].url).toBe(`${cloudUrl}/proxy`) + }) +}) + describe('buildMcpHeaders', () => { it('sets a plain Bearer Authorization header when a token is provided', () => { const headers = buildMcpHeaders('tok') diff --git a/src/lib/mcp-transport.ts b/src/lib/mcp-transport.ts index 705ba21aa..ac39251ca 100644 --- a/src/lib/mcp-transport.ts +++ b/src/lib/mcp-transport.ts @@ -8,8 +8,9 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import { isLoopbackUrl } from '@/acp/transports/is-loopback' import { getAuthToken } from './auth-token' -import { computeEffectiveProxyEnabled, createProxyFetch } from './proxy-fetch' +import { computeEffectiveProxyEnabled, createProxyFetch, type FetchFn } from './proxy-fetch' /** Remote transport kind. stdio (local) servers are connected by THU-575, not here. */ export type MCPTransportType = 'http' | 'sse' @@ -59,32 +60,55 @@ export const buildMcpHeaders = (token?: string): Record => { return headers } +/** Fetch shape the MCP SDK transports accept for `options.fetch`. */ +export type McpFetch = (input: string | URL, init?: RequestInit) => Promise + /** - * Builds an MCP client transport that routes through the universal proxy fetch. - * Hosted mode (web) goes through `${cloudUrl}/v1/proxy` with header rewriting; - * Standalone mode (Tauri) hits the upstream directly. Picks SSE for `sse`, - * otherwise Streamable HTTP — both accept the identical `{ fetch, requestInit }` - * shape. Keeps the provider and the settings test-connection on one code path. + * Picks the fetch implementation for an MCP `url`. A loopback target is the + * local stdio-bridge (it serves `http://127.0.0.1:PORT/mcp`): connect with the + * native `fetch` directly, bypassing the universal proxy — the proxy forbids + * loopback/private hosts, and a same-machine bridge needs no proxy hop. Remote + * targets route through `createProxyFetch` (Hosted: `${cloudUrl}/v1/proxy` with + * header rewriting; Standalone Tauri: upstream-direct). + * + * `nativeFetch` is a test seam — production omits it and the native path uses + * `globalThis.fetch`. */ -export const createMcpTransport = ( - url: string, - type: MCPTransportType, - cloudUrl: string, - headers: Record, -) => { - const urlObj = new URL(url) +export const resolveMcpFetch = (url: string, cloudUrl: string, nativeFetch?: typeof fetch): McpFetch => { + if (isLoopbackUrl(url)) { + const native = nativeFetch ?? globalThis.fetch + return (input, init) => native(input, init) + } // Authenticate the proxy hop with the Thunderbolt session bearer (the same getter the // app-wide ProxyFetchProvider uses) — without it `/v1/proxy` returns 401. The upstream // MCP credential rides separately as `X-Proxy-Passthrough-Authorization` (createProxyFetch // promotes the plain `Authorization` we set in buildMcpHeaders). `getProxyEnabled` honours // the Tauri standalone toggle; web always proxies (CORS forces it). - const proxyFetch = createProxyFetch({ + const proxyFetch: FetchFn = createProxyFetch({ cloudUrl, getProxyAuthToken: getAuthToken, getProxyEnabled: () => computeEffectiveProxyEnabled(), }) + return (input, init) => proxyFetch(input, init) +} + +/** + * Builds an MCP client transport. Remote servers route through the universal + * proxy fetch (Hosted web → `${cloudUrl}/v1/proxy` with header rewriting; + * Standalone Tauri → upstream-direct); a loopback stdio-bridge URL connects + * natively (see `resolveMcpFetch`). Picks SSE for `sse`, otherwise Streamable + * HTTP — both accept the identical `{ fetch, requestInit }` shape. Keeps the + * provider and the settings test-connection on one code path. + */ +export const createMcpTransport = ( + url: string, + type: MCPTransportType, + cloudUrl: string, + headers: Record, +) => { + const urlObj = new URL(url) const options = { - fetch: (input: string | URL, init?: RequestInit) => proxyFetch(input, init), + fetch: resolveMcpFetch(url, cloudUrl), requestInit: { headers }, } const transport = From 43e3f3a15f56cc481b755d6750e118696e20132f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 25 Jun 2026 09:26:44 -0300 Subject: [PATCH 04/45] refactor(THU-601): share bridge teardown latch and frame classifier, hoist hot-path work --- src/lib/mcp-transport.ts | 5 +- thunderbolt-stdio-bridge/bin/cli.js | 8 +-- thunderbolt-stdio-bridge/src/log.js | 27 ++++++++ thunderbolt-stdio-bridge/src/mcp-server.js | 69 +++++++++++-------- .../src/mcp-server.test.js | 12 ++-- thunderbolt-stdio-bridge/src/server.js | 32 +++------ thunderbolt-stdio-bridge/src/util.js | 25 +++++++ 7 files changed, 115 insertions(+), 63 deletions(-) diff --git a/src/lib/mcp-transport.ts b/src/lib/mcp-transport.ts index ac39251ca..a6502a28b 100644 --- a/src/lib/mcp-transport.ts +++ b/src/lib/mcp-transport.ts @@ -76,8 +76,7 @@ export type McpFetch = (input: string | URL, init?: RequestInit) => Promise { if (isLoopbackUrl(url)) { - const native = nativeFetch ?? globalThis.fetch - return (input, init) => native(input, init) + return nativeFetch ?? globalThis.fetch } // Authenticate the proxy hop with the Thunderbolt session bearer (the same getter the // app-wide ProxyFetchProvider uses) — without it `/v1/proxy` returns 401. The upstream @@ -89,7 +88,7 @@ export const resolveMcpFetch = (url: string, cloudUrl: string, nativeFetch?: typ getProxyAuthToken: getAuthToken, getProxyEnabled: () => computeEffectiveProxyEnabled(), }) - return (input, init) => proxyFetch(input, init) + return proxyFetch } /** diff --git a/thunderbolt-stdio-bridge/bin/cli.js b/thunderbolt-stdio-bridge/bin/cli.js index 001fc09d4..5816ba8f0 100644 --- a/thunderbolt-stdio-bridge/bin/cli.js +++ b/thunderbolt-stdio-bridge/bin/cli.js @@ -67,7 +67,7 @@ const run = async ({ const onSignal = deps.on ?? process.on.bind(process) const offSignal = deps.removeListener ?? process.removeListener.bind(process) - // 1) Parse argv. Help/version short-circuit to stdout (no child, no framing). + // Parse argv. Help/version short-circuit to stdout (no child, no framing). const parsed = (() => { try { return parseArgs(argv) @@ -91,7 +91,7 @@ const run = async ({ const logger = _makeLogger({ json: parsed.json, verbose: parsed.verbose, sink: stderr }) - // 3) Emit insecure-flag warnings before binding anything. + // Emit insecure-flag warnings before binding anything. for (const line of insecureFlagWarnings({ host: parsed.host, allowAnyOrigin: parsed.allowAnyOrigin, @@ -117,7 +117,7 @@ const run = async ({ exit(childExitToCode(info)) } - // 6) One-shot signal handlers -> graceful stop -> derived exit code. + // One-shot signal handlers -> graceful stop -> derived exit code. const handleSignal = (signal) => async () => { offSignal('SIGINT', sigintHandler) offSignal('SIGTERM', sigtermHandler) @@ -193,7 +193,7 @@ const run = async ({ } } -module.exports = { run, childExitToCode } +module.exports = { run } // Module side-effect entry: run when invoked as the program (not when required // by a test). Bundled or executed directly, this is the program entrypoint. diff --git a/thunderbolt-stdio-bridge/src/log.js b/thunderbolt-stdio-bridge/src/log.js index 6d8ead060..a640c68da 100644 --- a/thunderbolt-stdio-bridge/src/log.js +++ b/thunderbolt-stdio-bridge/src/log.js @@ -159,10 +159,37 @@ const classifyId = (frame) => { return 'absent' } +/** + * PII-safe classification of a parsed (or null) frame into the `{ method, id }` + * shape both faces log. A null/non-object frame yields the inert + * `{ method: 'unknown', id: 'absent' }` fallback (exactly what classifyMethod / + * classifyId already return for null). Never returns params/result/payload. + * @param {unknown} frame + * @returns {{ method: string, id: 'request'|'response'|'notification'|'absent' }} + */ +const classifyFrame = (frame) => ({ method: classifyMethod(frame), id: classifyId(frame) }) + +/** + * Parse-then-classify a raw NDJSON string into the PII-safe `{ method, id }` + * shape; a parse failure yields the inert `{ method: 'unknown', id: 'absent' }` + * fallback. Never throws and never returns the raw body. + * @param {string} raw + * @returns {{ method: string, id: 'request'|'response'|'notification'|'absent' }} + */ +const safeClassifyFrame = (raw) => { + try { + return classifyFrame(JSON.parse(raw)) + } catch { + return classifyFrame(null) + } +} + module.exports = { makeLogger, buildOriginAllowlist, classifyMethod, classifyId, + classifyFrame, + safeClassifyFrame, normalizeOrigin, } diff --git a/thunderbolt-stdio-bridge/src/mcp-server.js b/thunderbolt-stdio-bridge/src/mcp-server.js index cfd805da5..e8a6c052d 100644 --- a/thunderbolt-stdio-bridge/src/mcp-server.js +++ b/thunderbolt-stdio-bridge/src/mcp-server.js @@ -20,10 +20,10 @@ const { StreamableHTTPServerTransport: DefaultStreamableHTTPServerTransport, } = require('@modelcontextprotocol/sdk/server/streamableHttp.js') const { UnavailableError } = require('./errors') -const { buildOriginAllowlist, classifyMethod, classifyId } = require('./log') +const { buildOriginAllowlist, classifyFrame, safeClassifyFrame } = require('./log') const { createNdjsonReader, wsToFrame } = require('./relay') const { superviseChild: defaultSuperviseChild } = require('./child') -const { formatHostForUrl } = require('./util') +const { formatHostForUrl, makeCloseLatch } = require('./util') /** Default request body cap: 4 MiB. Larger POST bodies are rejected with 413. */ const DEFAULT_BODY_CAP_BYTES = 4 << 20 @@ -32,19 +32,22 @@ const MCP_PATH = '/mcp' /** Methods the transport handles on /mcp. Everything else is 404. */ const MCP_METHODS = new Set(['POST', 'GET', 'DELETE']) +/** SHA-256 digest a string to a fixed 32-byte buffer. */ +const sha256 = (value) => createHash('sha256').update(value).digest() + /** - * Constant-time bearer comparison that never leaks length. Both tokens are - * SHA-256 digested to fixed 32-byte buffers before timingSafeEqual, so unequal - * input lengths can never throw or short-circuit. + * Constant-time bearer comparison that never leaks length. The provided token is + * SHA-256 digested to a fixed 32-byte buffer and compared against the expected + * token's pre-computed digest (also 32 bytes), so unequal input lengths can + * never throw or short-circuit. The expected digest is hashed once per process + * (the bearer is fixed) rather than per request. * @param {string|undefined} provided - * @param {string} expected + * @param {Buffer} expectedDigest - sha256(expectedBearer) * @returns {boolean} */ -const bearerMatches = (provided, expected) => { +const bearerMatches = (provided, expectedDigest) => { if (typeof provided !== 'string') return false - const a = createHash('sha256').update(provided).digest() - const b = createHash('sha256').update(expected).digest() - return timingSafeEqual(a, b) + return timingSafeEqual(sha256(provided), expectedDigest) } /** Extract the `Bearer ` value from an Authorization header, or undefined. */ @@ -165,8 +168,12 @@ const startMcpFace = ({ const superviseChild = deps.superviseChild ?? defaultSuperviseChild const isOriginAllowed = buildOriginAllowlist({ allowOrigins, allowAnyOrigin }) + // The bearer is fixed for the process, so digest the expected value once here + // rather than per request; each request only digests the provided token. + const expectedDigest = bearer !== undefined ? sha256(bearer) : undefined + return new Promise((resolve, reject) => { - const closers = { settled: false, resolveClose: null } + const latch = makeCloseLatch() const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }) // HTTP -> child: every JSON-RPC message the transport surfaces is written to @@ -176,7 +183,7 @@ const startMcpFace = ({ try { supervisor.writeStdin(wsToFrame(JSON.stringify(message))) } catch { - logger.warn('drop-http-frame', { method: classifyMethod(message), id: classifyId(message) }) + logger.warn('drop-http-frame', classifyFrame(message)) } } @@ -187,7 +194,7 @@ const startMcpFace = ({ try { return JSON.parse(line) } catch { - logger.warn('drop-child-frame', { method: 'unknown', id: 'absent' }) + logger.warn('drop-child-frame', safeClassifyFrame(line)) return null } })() @@ -196,10 +203,16 @@ const startMcpFace = ({ const server = createServer() - const finishClose = () => { - if (closers.settled) return - closers.settled = true - if (closers.resolveClose) closers.resolveClose() + const finishClose = latch.finishClose + + // Close the transport then the http server, settling the close latch once the + // server's callback fires. Lingering keep-alive/stalled sockets are force-closed + // so finishClose fires promptly (server.close otherwise waits indefinitely). + // Shared by child-exit teardown and the resolved close(). + const shutdownHttp = () => { + transport.close() + server.close(finishClose) + if (typeof server.closeAllConnections === 'function') server.closeAllConnections() } const supervisor = superviseChild({ @@ -209,19 +222,21 @@ const startMcpFace = ({ onStdout: (chunk) => reader.push(chunk), onExit: (info) => { reader.flush() - transport.close() - server.close(finishClose) - // Force lingering keep-alive sockets closed so finishClose fires promptly. - if (typeof server.closeAllConnections === 'function') server.closeAllConnections() + shutdownHttp() if (onChildExit) onChildExit(info) }, onSpawnError: (err) => { server.close() - if (!closers.settled) reject(new UnavailableError({ code: err.code })) + if (!latch.settled()) reject(new UnavailableError({ code: err.code })) }, }) const applyCors = (req, res) => { + // Origin asymmetry vs the ACP face: ACP hard-rejects a disallowed Origin at + // the WebSocket upgrade (verifyClient), but a non-browser MCP client + // legitimately sends no Origin, so this face relies on browser-enforced CORS + // — it withholds the ACAO header for a disallowed Origin rather than + // rejecting the request server-side. const origin = req.headers.origin if (allowAnyOrigin) { res.setHeader('Access-Control-Allow-Origin', '*') @@ -236,7 +251,7 @@ const startMcpFace = ({ server.on('request', async (req, res) => { // BEARER-BEFORE-ROUTE: the very first check, before CORS/routing/parsing. - if (bearer !== undefined && !bearerMatches(readBearer(req), bearer)) { + if (expectedDigest !== undefined && !bearerMatches(readBearer(req), expectedDigest)) { replyStatus(res, 401) return } @@ -289,13 +304,9 @@ const startMcpFace = ({ kill: () => supervisor.kill(), // immediate SIGKILL — never-orphan backstop close: () => new Promise((resolveOuter) => { - closers.resolveClose = resolveOuter - transport.close() + latch.setResolver(resolveOuter) supervisor.stop() // grace -> SIGKILL, never-orphan - server.close(finishClose) - // Force lingering keep-alive/stalled sockets closed so finishClose - // fires promptly (server.close otherwise waits for them indefinitely). - if (typeof server.closeAllConnections === 'function') server.closeAllConnections() + shutdownHttp() }), }) }) diff --git a/thunderbolt-stdio-bridge/src/mcp-server.test.js b/thunderbolt-stdio-bridge/src/mcp-server.test.js index cc99ee0ea..3198fd5cc 100644 --- a/thunderbolt-stdio-bridge/src/mcp-server.test.js +++ b/thunderbolt-stdio-bridge/src/mcp-server.test.js @@ -6,8 +6,12 @@ const { test, expect, mock } = require('bun:test') const { EventEmitter } = require('node:events') +const { createHash } = require('node:crypto') const { startMcpFace, bearerMatches, BODY_ABORTED } = require('./mcp-server') +/** SHA-256 digest a string to a 32-byte buffer, matching the expected-bearer digest. */ +const digest = (value) => createHash('sha256').update(value).digest() + /** A silent PII-safe logger spy. */ const makeLogger = () => ({ info: mock(() => {}), @@ -195,10 +199,10 @@ test('bearer set: a correct bearer passes and is dispatched', async () => { }) test('bearerMatches: unequal-length inputs fail without throwing', () => { - expect(() => bearerMatches('a', 'abcdef')).not.toThrow() - expect(bearerMatches('a', 'abcdef')).toBe(false) - expect(bearerMatches(undefined, 'abc')).toBe(false) - expect(bearerMatches('abc', 'abc')).toBe(true) + expect(() => bearerMatches('a', digest('abcdef'))).not.toThrow() + expect(bearerMatches('a', digest('abcdef'))).toBe(false) + expect(bearerMatches(undefined, digest('abc'))).toBe(false) + expect(bearerMatches('abc', digest('abc'))).toBe(true) }) test('CORS preflight (OPTIONS) returns 204 and allow-origin per allowlist', async () => { diff --git a/thunderbolt-stdio-bridge/src/server.js b/thunderbolt-stdio-bridge/src/server.js index 82f7517c7..9682bfdf8 100644 --- a/thunderbolt-stdio-bridge/src/server.js +++ b/thunderbolt-stdio-bridge/src/server.js @@ -11,10 +11,10 @@ 'use strict' const { UnavailableError } = require('./errors') -const { buildOriginAllowlist, classifyMethod, classifyId } = require('./log') +const { buildOriginAllowlist, safeClassifyFrame } = require('./log') const { createNdjsonReader, frameToWs, wsToFrame } = require('./relay') const { superviseChild: defaultSuperviseChild } = require('./child') -const { formatHostForUrl } = require('./util') +const { formatHostForUrl, makeCloseLatch } = require('./util') /** Pause child stdout once a socket buffers more than this many bytes. */ const HIGH_WATER = 1 << 20 // 1 MiB @@ -23,16 +23,6 @@ const LOW_WATER = 1 << 18 // 256 KiB /** Normal WS close code for a superseded/torn-down client. */ const CLOSE_NORMAL = 1000 -/** PII-safe classification of a raw frame; never throws and never returns body. */ -const safeClassify = (raw) => { - try { - const frame = JSON.parse(raw) - return { method: classifyMethod(frame), id: classifyId(frame) } - } catch { - return { method: 'unknown', id: 'absent' } - } -} - /** * Start the ACP WebSocket face: bind, spawn the child, and bridge NDJSON stdio * to a single newest-wins WebSocket client with an Origin gate and backpressure. @@ -65,7 +55,7 @@ const startBridge = ({ const isOriginAllowed = buildOriginAllowlist({ allowOrigins, allowAnyOrigin }) return new Promise((resolve, reject) => { - const closers = { resolveClose: null, settled: false } + const latch = makeCloseLatch() let client = null let supervisor = null let paused = false @@ -92,7 +82,7 @@ const startBridge = ({ try { sendToClient(line) } catch { - logger.warn('drop-child-frame', safeClassify(line)) + logger.warn('drop-child-frame', safeClassifyFrame(line)) } }) @@ -104,11 +94,7 @@ const startBridge = ({ verifyClient: ({ origin }) => isOriginAllowed(origin), }) - const finishClose = () => { - if (closers.settled) return - closers.settled = true - if (closers.resolveClose) closers.resolveClose() - } + const finishClose = latch.finishClose supervisor = superviseChild({ launch, @@ -124,7 +110,7 @@ const startBridge = ({ onSpawnError: (err) => { // Spawn ENOENT etc. — tear the server down and surface as unavailable. wss.close() - if (!closers.settled) reject(new UnavailableError({ code: err.code })) + if (!latch.settled()) reject(new UnavailableError({ code: err.code })) }, }) @@ -159,7 +145,7 @@ const startBridge = ({ } })() if (frame === null) { - logger.warn('drop-ws-frame', safeClassify(raw)) + logger.warn('drop-ws-frame', safeClassifyFrame(raw)) return } @@ -187,12 +173,12 @@ const startBridge = ({ close: () => new Promise((resolveOuter) => { // Already torn down (e.g. child exited first): resolve immediately. - if (closers.settled) { + if (latch.settled()) { supervisor.stop() // idempotent no-op once the child is gone resolveOuter() return } - closers.resolveClose = resolveOuter + latch.setResolver(resolveOuter) if (client) client.close(CLOSE_NORMAL) supervisor.stop() // grace -> SIGKILL, never-orphan wss.close(finishClose) diff --git a/thunderbolt-stdio-bridge/src/util.js b/thunderbolt-stdio-bridge/src/util.js index f599d2f2a..ccede6c63 100644 --- a/thunderbolt-stdio-bridge/src/util.js +++ b/thunderbolt-stdio-bridge/src/util.js @@ -49,6 +49,30 @@ const isLoopbackHost = (host) => { return octets[0] === '127' && octets.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255) } +/** + * A resolve-once close latch shared by both faces. `settle` runs the bound + * resolver exactly once (later calls are no-ops); `close()` first sets the + * resolver then triggers teardown, so the resolver fires when teardown + * completes. Centralizes the never-orphan teardown semantics so the two faces + * can't drift. + * @returns {{ finishClose: () => void, setResolver: (fn: () => void) => void, settled: () => boolean }} + */ +const makeCloseLatch = () => { + let settled = false + let resolveClose = null + return { + finishClose: () => { + if (settled) return + settled = true + if (resolveClose) resolveClose() + }, + setResolver: (fn) => { + resolveClose = fn + }, + settled: () => settled, + } +} + /** * Build the list of loud warning lines to emit before binding. Empty array when * the config is safe. Builds messages only — printing is the caller's job, so @@ -76,4 +100,5 @@ module.exports = { formatHostForUrl, isLoopbackHost, insecureFlagWarnings, + makeCloseLatch, } From 204d3fb6e9b1a08bb8b89dfd54c15ae68baec9e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 25 Jun 2026 09:57:16 -0300 Subject: [PATCH 05/45] fix(THU-601): tunnel real bound port, reject disallowed MCP origins, and fix teardown hangs --- thunderbolt-stdio-bridge/bin/cli.js | 28 ++-- thunderbolt-stdio-bridge/bin/cli.test.js | 48 +++++- thunderbolt-stdio-bridge/src/log.test.js | 14 +- thunderbolt-stdio-bridge/src/mcp-server.js | 52 +++++-- .../src/mcp-server.test.js | 139 +++++++++++++++++- thunderbolt-stdio-bridge/src/server.js | 10 +- thunderbolt-stdio-bridge/src/server.test.js | 42 +++++- thunderbolt-stdio-bridge/src/tunnel.js | 54 ++++--- thunderbolt-stdio-bridge/src/tunnel.test.js | 93 ++++-------- thunderbolt-stdio-bridge/src/util.js | 12 +- 10 files changed, 353 insertions(+), 139 deletions(-) diff --git a/thunderbolt-stdio-bridge/bin/cli.js b/thunderbolt-stdio-bridge/bin/cli.js index 5816ba8f0..c2259f45b 100644 --- a/thunderbolt-stdio-bridge/bin/cli.js +++ b/thunderbolt-stdio-bridge/bin/cli.js @@ -17,7 +17,7 @@ const { makeLogger } = require('../src/log') const { insecureFlagWarnings } = require('../src/util') const { startBridge } = require('../src/server') const { startMcpFace } = require('../src/mcp-server') -const { startTunnel } = require('../src/tunnel') +const { startTunnel, generateBearer } = require('../src/tunnel') // esbuild inlines this; a fallback keeps the un-bundled bin runnable from source. const BRIDGE_VERSION = typeof __BRIDGE_VERSION__ !== 'undefined' ? __BRIDGE_VERSION__ : '0.0.0-dev' @@ -50,7 +50,7 @@ Options: * @param {NodeJS.WritableStream} [opts.stdout] - help/version sink only. * @param {NodeJS.WritableStream} [opts.stderr] - all diagnostics + banner. * @param {(code: number) => void} [opts.exit] - * @param {Object} [opts.deps] - injectable { startBridge, startMcpFace, startTunnel, makeLogger, on, removeListener }. + * @param {Object} [opts.deps] - injectable { startBridge, startMcpFace, startTunnel, generateBearer, makeLogger, on, removeListener }. * @returns {Promise} */ const run = async ({ @@ -63,6 +63,7 @@ const run = async ({ const _startBridge = deps.startBridge ?? startBridge const _startMcpFace = deps.startMcpFace ?? startMcpFace const _startTunnel = deps.startTunnel ?? startTunnel + const _generateBearer = deps.generateBearer ?? generateBearer const _makeLogger = deps.makeLogger ?? makeLogger const onSignal = deps.on ?? process.on.bind(process) const offSignal = deps.removeListener ?? process.removeListener.bind(process) @@ -161,29 +162,26 @@ const run = async ({ return } - // mode === 'mcp' - const bearerSource = parsed.tunnel - ? await (async () => { - const tunnel = await _startTunnel({ - localUrl: `http://127.0.0.1:${parsed.port}/mcp`, - logger, - }) - live.tunnel = tunnel - return tunnel.bearer - })() - : undefined - + // mode === 'mcp'. Mint the bearer first, bind the face, THEN tunnel to the + // face's REAL bound URL — never a pre-bind port-0 placeholder. The same + // bearer fronts both the local face and the public tunnel. + const bearer = parsed.tunnel ? _generateBearer() : undefined const face = await _startMcpFace({ launch: parsed.launch, host: parsed.host, port: parsed.port, - bearer: bearerSource, + bearer, allowOrigins: parsed.allowOrigins, allowAnyOrigin: parsed.allowAnyOrigin, logger, onChildExit, }) live.face = face + + if (parsed.tunnel) { + // If the tunnel fails here the catch path reaps live.face — never-orphan. + live.tunnel = await _startTunnel({ localUrl: face.url, bearer, logger }) + } return } catch (err) { await reap() // never-orphan before exiting on any fatal path diff --git a/thunderbolt-stdio-bridge/bin/cli.test.js b/thunderbolt-stdio-bridge/bin/cli.test.js index 7d46ed57c..e06ad6b31 100644 --- a/thunderbolt-stdio-bridge/bin/cli.test.js +++ b/thunderbolt-stdio-bridge/bin/cli.test.js @@ -17,10 +17,12 @@ const makeSink = () => { const makeHarness = (over = {}) => { const signals = {} const face = { url: 'ws://127.0.0.1:5000', close: mock(async () => {}), kill: mock(() => {}) } + const mcpFace = { url: 'http://127.0.0.1:54321/mcp', close: mock(async () => {}), kill: mock(() => {}) } const tunnel = { publicUrl: 'https://x.trycloudflare.com', bearer: 'secret', close: mock(async () => {}) } const startBridge = mock(async () => face) - const startMcpFace = mock(async () => face) + const startMcpFace = mock(async () => mcpFace) const startTunnel = mock(async () => tunnel) + const generateBearer = mock(() => 'minted-bearer') const logger = { info: mock(() => {}), warn: mock(() => {}), error: mock(() => {}), banner: mock(() => {}) } const makeLogger = mock(() => logger) const exit = mock(() => {}) @@ -30,6 +32,7 @@ const makeHarness = (over = {}) => { startBridge, startMcpFace, startTunnel, + generateBearer, makeLogger, on: (sig, fn) => { signals[sig] = fn @@ -37,7 +40,22 @@ const makeHarness = (over = {}) => { removeListener: () => {}, ...over.deps, } - return { face, tunnel, startBridge, startMcpFace, startTunnel, logger, makeLogger, exit, stdout, stderr, signals, deps } + return { + face, + mcpFace, + tunnel, + startBridge, + startMcpFace, + startTunnel, + generateBearer, + logger, + makeLogger, + exit, + stdout, + stderr, + signals, + deps, + } } test('--help prints usage to stdout and exits 0 (no child spawned)', async () => { @@ -79,8 +97,19 @@ test('--mode acp -- dispatches to startBridge with parsed launch + options expect(arg.allowOrigins).toEqual(['http://a']) }) -test('--mode mcp -- dispatches to startMcpFace; --tunnel invokes startTunnel and threads bearer', async () => { +test('--mode mcp --tunnel: face binds BEFORE the tunnel, which targets the face url with the same minted bearer', async () => { const h = makeHarness() + const order = [] + const startMcpFace = mock(async () => { + order.push('face') + return h.mcpFace + }) + const startTunnel = mock(async () => { + order.push('tunnel') + return h.tunnel + }) + h.deps.startMcpFace = startMcpFace + h.deps.startTunnel = startTunnel await run({ argv: ['--mode', 'mcp', '--tunnel', '--', 'srv'], stdout: h.stdout, @@ -88,9 +117,16 @@ test('--mode mcp -- dispatches to startMcpFace; --tunnel invokes startTunn exit: h.exit, deps: h.deps, }) - expect(h.startTunnel).toHaveBeenCalledTimes(1) - expect(h.startMcpFace).toHaveBeenCalledTimes(1) - expect(h.startMcpFace.mock.calls[0][0].bearer).toBe('secret') + expect(startMcpFace).toHaveBeenCalledTimes(1) + expect(startTunnel).toHaveBeenCalledTimes(1) + // The face must bind before the tunnel so the tunnel targets a REAL bound url. + expect(order).toEqual(['face', 'tunnel']) + // The tunnel points at the face's resolved url — never a pre-bind port-0 placeholder. + expect(startTunnel.mock.calls[0][0].localUrl).toBe(h.mcpFace.url) + // One bearer is minted and threaded into BOTH the face and the tunnel. + expect(h.generateBearer).toHaveBeenCalledTimes(1) + expect(startMcpFace.mock.calls[0][0].bearer).toBe('minted-bearer') + expect(startTunnel.mock.calls[0][0].bearer).toBe('minted-bearer') }) test('--mode mcp without --tunnel does not start a tunnel and bearer is undefined', async () => { diff --git a/thunderbolt-stdio-bridge/src/log.test.js b/thunderbolt-stdio-bridge/src/log.test.js index 645a35eea..1a33ae57d 100644 --- a/thunderbolt-stdio-bridge/src/log.test.js +++ b/thunderbolt-stdio-bridge/src/log.test.js @@ -5,7 +5,7 @@ 'use strict' const { test, expect } = require('bun:test') -const { makeLogger, buildOriginAllowlist, classifyMethod, classifyId } = require('./log') +const { makeLogger, buildOriginAllowlist, classifyMethod, classifyId, classifyFrame, safeClassifyFrame } = require('./log') /** A fake writable sink that records every written line. */ const makeSink = () => { @@ -118,6 +118,18 @@ test('classifyId distinguishes request/response/notification/absent without leak expect(classifyId({})).toBe('absent') }) +test('classifyFrame(null) → the inert no-leak fallback shape', () => { + expect(classifyFrame(null)).toEqual({ method: 'unknown', id: 'absent' }) +}) + +test('safeClassifyFrame: a bad-JSON raw string → the inert { method: unknown, id: absent } fallback', () => { + expect(safeClassifyFrame('{bad json')).toEqual({ method: 'unknown', id: 'absent' }) +}) + +test('safeClassifyFrame: a well-formed request frame → { method, id: request } and never the id value', () => { + expect(safeClassifyFrame('{"method":"x","id":1}')).toEqual({ method: 'x', id: 'request' }) +}) + test('error() logs the passed errorCode and never a message/stack string', () => { const sink = makeSink() const logger = makeLogger({ json: true, verbose: false, sink }) diff --git a/thunderbolt-stdio-bridge/src/mcp-server.js b/thunderbolt-stdio-bridge/src/mcp-server.js index e8a6c052d..14da3ec6c 100644 --- a/thunderbolt-stdio-bridge/src/mcp-server.js +++ b/thunderbolt-stdio-bridge/src/mcp-server.js @@ -188,7 +188,10 @@ const startMcpFace = ({ } // child stdout NDJSON -> HTTP: each complete line is parsed and pushed to the - // transport, which routes it to the correct pending HTTP/SSE response. + // transport, which routes it to the correct pending HTTP/SSE response. The + // SDK's send can reject when the target client has disconnected — a benign, + // per-frame condition, not a bridge fault — so swallow it (logged PII-safe) + // instead of letting an unhandled rejection reach the never-orphan backstop. const reader = createNdjsonReader((line) => { const message = (() => { try { @@ -198,7 +201,11 @@ const startMcpFace = ({ return null } })() - if (message) transport.send(message) + if (message) { + Promise.resolve(transport.send(message)).catch(() => + logger.warn('drop-child-frame', classifyFrame(message)), + ) + } }) const server = createServer() @@ -232,11 +239,6 @@ const startMcpFace = ({ }) const applyCors = (req, res) => { - // Origin asymmetry vs the ACP face: ACP hard-rejects a disallowed Origin at - // the WebSocket upgrade (verifyClient), but a non-browser MCP client - // legitimately sends no Origin, so this face relies on browser-enforced CORS - // — it withholds the ACAO header for a disallowed Origin rather than - // rejecting the request server-side. const origin = req.headers.origin if (allowAnyOrigin) { res.setHeader('Access-Control-Allow-Origin', '*') @@ -249,13 +251,42 @@ const startMcpFace = ({ res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id') } + // Hand a request to the transport, swallowing a benign rejection (a client + // that disconnected mid-response). The SDK can reject from handleRequest the + // same way it can from send; a stale client is not a fatal bridge fault, so + // it must never reach the never-orphan backstop. Reply 500 only if the socket + // is still writable; otherwise the response is already gone — just drop. The + // caller-supplied thunk runs handleRequest with the right arity (the POST path + // always passes its parsed body, possibly undefined; GET/DELETE pass none). + const dispatch = (res, handle) => { + Promise.resolve(handle()).catch((err) => { + logger.warn('drop-http-frame', { errorCode: err && err.code }) + if (res.writable && !res.writableEnded && !res.headersSent) replyStatus(res, 500) + }) + } + server.on('request', async (req, res) => { - // BEARER-BEFORE-ROUTE: the very first check, before CORS/routing/parsing. + // BEARER-BEFORE-ROUTE: the very first check, before the Origin gate, CORS, + // routing, or parsing. if (expectedDigest !== undefined && !bearerMatches(readBearer(req), expectedDigest)) { replyStatus(res, 401) return } + // ORIGIN GATE (server-side, default-on): mirror the ACP face's hard reject. + // A cross-origin *simple* POST (text/plain JSON-RPC body) is not preflighted, + // so withholding the ACAO header alone does NOT stop the request from + // executing — CORS only blocks the page from reading the response, not from + // sending it. In the default no-bearer local mode that is a CSRF hole, so a + // PRESENT, disallowed Origin is rejected 403 before any routing. An ABSENT + // Origin is allowed (non-browser clients legitimately send none); when + // --allow-any-origin is set isOriginAllowed returns true and this is a no-op. + const origin = req.headers.origin + if (typeof origin === 'string' && !isOriginAllowed(origin)) { + replyStatus(res, 403) + return + } + applyCors(req, res) if (req.method === 'OPTIONS') { replyStatus(res, 204) @@ -280,11 +311,11 @@ const startMcpFace = ({ replyStatus(res, 400) return } - transport.handleRequest(req, res, parsed) + dispatch(res, () => transport.handleRequest(req, res, parsed)) return } - transport.handleRequest(req, res) + dispatch(res, () => transport.handleRequest(req, res)) }) server.on('error', (err) => { @@ -318,5 +349,4 @@ module.exports = { bearerMatches, DEFAULT_BODY_CAP_BYTES, MCP_PATH, - BODY_ABORTED, } diff --git a/thunderbolt-stdio-bridge/src/mcp-server.test.js b/thunderbolt-stdio-bridge/src/mcp-server.test.js index 3198fd5cc..c78878963 100644 --- a/thunderbolt-stdio-bridge/src/mcp-server.test.js +++ b/thunderbolt-stdio-bridge/src/mcp-server.test.js @@ -7,7 +7,7 @@ const { test, expect, mock } = require('bun:test') const { EventEmitter } = require('node:events') const { createHash } = require('node:crypto') -const { startMcpFace, bearerMatches, BODY_ABORTED } = require('./mcp-server') +const { startMcpFace, bearerMatches } = require('./mcp-server') /** SHA-256 digest a string to a 32-byte buffer, matching the expected-bearer digest. */ const digest = (value) => createHash('sha256').update(value).digest() @@ -205,6 +205,18 @@ test('bearerMatches: unequal-length inputs fail without throwing', () => { expect(bearerMatches('abc', digest('abc'))).toBe(true) }) +test('bearerMatches: a SAME-LENGTH near-miss (32-byte digests differing by one byte) → false', () => { + // Both operands are always 32-byte sha256 digests, so this exercises the real + // constant-time path: equal-length buffers that differ in exactly one byte. + const provided = 'sekret' + const nearMiss = Buffer.from(digest(provided)) // copy of sha256(provided) + nearMiss[0] ^= 0x01 // flip a single bit in one byte + expect(nearMiss).toHaveLength(32) + expect(bearerMatches(provided, nearMiss)).toBe(false) + // Sanity: the un-flipped digest still matches. + expect(bearerMatches(provided, digest(provided))).toBe(true) +}) + test('CORS preflight (OPTIONS) returns 204 and allow-origin per allowlist', async () => { const logger = makeLogger() const { server, deps } = makeHarness() @@ -274,10 +286,6 @@ test('a client that closes the POST socket before end is dropped: no reply, no h expect(transport.handleRequest).not.toHaveBeenCalled() }) -test('BODY_ABORTED is an exported sentinel distinct from a real body', () => { - expect(typeof BODY_ABORTED).toBe('symbol') -}) - test('a disallowed Origin is not granted CORS access', async () => { const logger = makeLogger() const { server, deps } = makeHarness() @@ -288,6 +296,51 @@ test('a disallowed Origin is not granted CORS access', async () => { expect(res.headers['Access-Control-Allow-Origin']).toBeUndefined() }) +test('a present, disallowed Origin POST is hard-rejected 403 server-side and NOT dispatched', async () => { + const logger = makeLogger() + const { server, transport, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps)) + // A cross-origin simple POST is not preflighted; the server-side gate must + // reject it (CSRF defense) rather than merely withholding the ACAO header. + const req = makeReq({ headers: { origin: 'http://evil.com' }, body: '{"jsonrpc":"2.0","method":"tools/call","id":1}' }) + const res = makeRes() + await fireRequest(server, req, res) + expect(res.statusCode).toBe(403) + expect(transport.handleRequest).not.toHaveBeenCalled() +}) + +test('an absent Origin is allowed (non-browser client) and dispatched', async () => { + const logger = makeLogger() + const { server, transport, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps)) + const req = makeReq({ headers: {}, body: '{"jsonrpc":"2.0","method":"ping","id":1}' }) + const res = makeRes() + await fireRequest(server, req, res) + expect(res.statusCode).not.toBe(403) + expect(transport.handleRequest).toHaveBeenCalledTimes(1) +}) + +test('an allowed (loopback) Origin POST passes the gate and is dispatched', async () => { + const logger = makeLogger() + const { server, transport, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps)) + const req = makeReq({ headers: { origin: 'http://localhost:5173' }, body: '{"jsonrpc":"2.0","method":"ping","id":1}' }) + const res = makeRes() + await fireRequest(server, req, res) + expect(transport.handleRequest).toHaveBeenCalledTimes(1) +}) + +test('--allow-any-origin lets any Origin (incl. evil.com) through the gate and dispatch', async () => { + const logger = makeLogger() + const { server, transport, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps, { allowAnyOrigin: true })) + const req = makeReq({ headers: { origin: 'http://evil.com' }, body: '{"jsonrpc":"2.0","method":"ping","id":1}' }) + const res = makeRes() + await fireRequest(server, req, res) + expect(res.statusCode).not.toBe(403) + expect(transport.handleRequest).toHaveBeenCalledTimes(1) +}) + test('body exceeding bodyCapBytes → 413 and is not dispatched', async () => { const logger = makeLogger() const { server, transport, deps } = makeHarness() @@ -332,6 +385,46 @@ test('POST /mcp is dispatched to transport.handleRequest with the parsed body', expect(parsed).toEqual({ jsonrpc: '2.0', method: 'initialize', id: 1 }) }) +test('GET /mcp is dispatched to transport.handleRequest (SSE stream open)', async () => { + const logger = makeLogger() + const { server, transport, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps)) + const req = makeReq({ method: 'GET', url: '/mcp' }) + const res = makeRes() + await fireRequest(server, req, res) + expect(transport.handleRequest).toHaveBeenCalledTimes(1) + // GET carries no body: handleRequest is invoked with exactly two args. + expect(transport.handleRequest.mock.calls[0]).toHaveLength(2) +}) + +test('DELETE /mcp is dispatched to transport.handleRequest (session teardown)', async () => { + const logger = makeLogger() + const { server, transport, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps)) + const req = makeReq({ method: 'DELETE', url: '/mcp' }) + const res = makeRes() + await fireRequest(server, req, res) + expect(transport.handleRequest).toHaveBeenCalledTimes(1) + expect(transport.handleRequest.mock.calls[0]).toHaveLength(2) +}) + +test('a rejecting transport.handleRequest is swallowed + logged, never escapes', async () => { + const logger = makeLogger() + const { server, transport, deps } = makeHarness() + transport.handleRequest = mock(() => Promise.reject(Object.assign(new Error('gone'), { code: 'ERR_CLOSED' }))) + const rejections = [] + const onRejection = (err) => rejections.push(err) + process.on('unhandledRejection', onRejection) + await startMcpFace(baseOpts(logger, deps)) + const req = makeReq({ method: 'GET', url: '/mcp' }) + const res = makeRes() + await fireRequest(server, req, res) + await new Promise((r) => setTimeout(r, 0)) + process.removeListener('unhandledRejection', onRejection) + expect(rejections).toHaveLength(0) + expect(logger.warn).toHaveBeenCalled() +}) + test('HTTP onmessage relays to child stdin as one NDJSON line', async () => { const logger = makeLogger() const { transport, calls, deps } = makeHarness() @@ -350,6 +443,24 @@ test('child stdout NDJSON lines are parsed and sent to the transport', async () expect(transport.send.mock.calls[0][0]).toEqual({ jsonrpc: '2.0', result: {}, id: 7 }) }) +test('a rejecting transport.send (disconnected client) is swallowed + logged, never escapes', async () => { + const logger = makeLogger() + const { transport, hooks, deps } = makeHarness() + // A stale/disconnected client makes the SDK reject; this must not surface as an + // unhandledRejection (which the CLI's onFatal backstop would treat as fatal). + transport.send = mock(() => Promise.reject(Object.assign(new Error('closed'), { code: 'ERR_CLOSED' }))) + const rejections = [] + const onRejection = (err) => rejections.push(err) + process.on('unhandledRejection', onRejection) + await startMcpFace(baseOpts(logger, deps)) + hooks.onStdout(Buffer.from('{"jsonrpc":"2.0","result":{},"id":7}\n')) + await new Promise((r) => setTimeout(r, 0)) + process.removeListener('unhandledRejection', onRejection) + expect(rejections).toHaveLength(0) + expect(transport.send).toHaveBeenCalledTimes(1) + expect(logger.warn).toHaveBeenCalled() +}) + test('a malformed child stdout line is dropped + logged by method/id only, never sent', async () => { const logger = makeLogger() const { transport, hooks, deps } = makeHarness() @@ -396,6 +507,20 @@ test('close() closes transport, stops the child, and closes the server: no orpha expect(server.close).toHaveBeenCalled() }) +test('close() AFTER the child self-exited resolves (no hang) and is idempotent', async () => { + const logger = makeLogger() + const { hooks, deps } = makeHarness() + const face = await startMcpFace(baseOpts(logger, deps)) + // Drive the child self-exit first: this settles the close latch via finishClose. + hooks.onExit({ code: 0, signal: null }) + // A close() on the already-settled latch must resolve immediately (setResolver + // runs the resolver synchronously) rather than wait forever for a finishClose + // that already fired. + await face.close() + // Idempotent: a second close() also resolves. + await face.close() +}) + test('close() force-closes lingering sockets and resolves deterministically (no hang)', async () => { const logger = makeLogger() const { server, deps } = makeHarness() @@ -416,7 +541,7 @@ test('the resolved face exposes kill() which immediately SIGKILLs the child', as expect(calls.killed).toBe(1) }) -test('a spawn ENOENT rejects with an unavailable error and SIGKILLs the child first', async () => { +test('a spawn ENOENT rejects with an unavailable error and only closes the server (no kill — there is no child)', async () => { const logger = makeLogger() const { server, hooks, calls, deps } = makeHarness() const promise = startMcpFace(baseOpts(logger, deps)) @@ -424,6 +549,8 @@ test('a spawn ENOENT rejects with an unavailable error and SIGKILLs the child fi hooks.onSpawnError(Object.assign(new Error('enoent'), { code: 'ENOENT' })) await expect(promise).rejects.toMatchObject({ name: 'UnavailableError', code: 'ENOENT' }) expect(server.close).toHaveBeenCalled() + // The child never spawned, so onSpawnError must not attempt a kill. + expect(calls.killed).toBe(0) }) test('a bind failure rejects with an unavailable error and SIGKILLs the child first', async () => { diff --git a/thunderbolt-stdio-bridge/src/server.js b/thunderbolt-stdio-bridge/src/server.js index 9682bfdf8..892538f5c 100644 --- a/thunderbolt-stdio-bridge/src/server.js +++ b/thunderbolt-stdio-bridge/src/server.js @@ -172,15 +172,11 @@ const startBridge = ({ kill: () => supervisor.kill(), // immediate SIGKILL — never-orphan backstop close: () => new Promise((resolveOuter) => { - // Already torn down (e.g. child exited first): resolve immediately. - if (latch.settled()) { - supervisor.stop() // idempotent no-op once the child is gone - resolveOuter() - return - } + // If the child already exited the latch is settled and setResolver + // resolves synchronously; otherwise the resolver fires on finishClose. latch.setResolver(resolveOuter) if (client) client.close(CLOSE_NORMAL) - supervisor.stop() // grace -> SIGKILL, never-orphan + supervisor.stop() // grace -> SIGKILL (idempotent once gone), never-orphan wss.close(finishClose) }), }) diff --git a/thunderbolt-stdio-bridge/src/server.test.js b/thunderbolt-stdio-bridge/src/server.test.js index 0712b9376..6a3845113 100644 --- a/thunderbolt-stdio-bridge/src/server.test.js +++ b/thunderbolt-stdio-bridge/src/server.test.js @@ -150,18 +150,18 @@ test('a listen EADDRINUSE rejects with an unavailable error and SIGKILLs the chi expect(sup.kill).toHaveBeenCalledTimes(1) }) -test('Origin gate rejects a disallowed Origin and accepts a loopback/undefined Origin', async () => { - const { getOpts, promise } = start() +test('Origin gate rejects a disallowed Origin and accepts a loopback/undefined Origin', () => { + // verifyClient is captured synchronously at WebSocketServer construction, so + // this asserts the gate predicate without resolving the (intentionally + // unobserved) listening promise. + const { getOpts } = start() const verifyClient = getOpts().verifyClient expect(verifyClient({ origin: 'http://evil.com' })).toBe(false) expect(verifyClient({ origin: 'http://localhost:3000' })).toBe(true) expect(verifyClient({ origin: undefined })).toBe(true) - // resolve the dangling promise - getOpts() // noop; clean up by emitting listening through a fresh handle below - await Promise.resolve() }) -test('--allow-any-origin accepts any Origin (gate disabled)', async () => { +test('--allow-any-origin accepts any Origin (gate disabled)', () => { const { getOpts } = start({ allowAnyOrigin: true }) expect(getOpts().verifyClient({ origin: 'http://evil.com' })).toBe(true) }) @@ -427,6 +427,36 @@ test('child exit closes the server + client(1000) and resolves close()', async ( await face.close() }) +test('close() AFTER the child self-exited resolves (no hang) and is idempotent', async () => { + let wssRef + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o) { + super(o) + wssRef = this + } + } + const { sup, factory } = makeFakeSupervisor() + const p = startBridge({ + launch: ['x'], + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + logger: noopLogger, + deps: { WebSocketServer: Capture, superviseChild: factory }, + }) + wssRef.emit('listening') + const face = await p + // Drive the child self-exit first: this settles the close latch via finishClose. + sup.onExit({ code: 0, signal: null }) + // close() on the already-settled latch resolves immediately (setResolver runs + // the resolver synchronously) rather than hang on a finishClose that already fired. + await face.close() + // Idempotent: a second close() also resolves. + await face.close() +}) + test('the resolved face exposes kill() which immediately SIGKILLs the child', async () => { let wssRef const { FakeWss } = makeFakeWss() diff --git a/thunderbolt-stdio-bridge/src/tunnel.js b/thunderbolt-stdio-bridge/src/tunnel.js index 66af61adc..b07cebcc2 100644 --- a/thunderbolt-stdio-bridge/src/tunnel.js +++ b/thunderbolt-stdio-bridge/src/tunnel.js @@ -2,12 +2,12 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. -// Stand up a cloudflared quick tunnel in front of the local MCP face (MCP only) -// and MINT a mandatory bearer. Spawns `cloudflared tunnel --url `, -// parses the assigned *.trycloudflare.com URL from cloudflared's stderr, and -// generates a high-entropy bearer. The bearer is printed to STDERR ONLY and -// never embedded in the public URL or any query string — mcp-server.js compares -// it constant-time on every request. +// Stand up a cloudflared quick tunnel in front of the local MCP face (MCP only). +// Spawns `cloudflared tunnel --url `, parses the assigned +// *.trycloudflare.com URL from cloudflared's stderr, and fronts the face with a +// caller-minted mandatory bearer. The bearer is printed to STDERR ONLY and never +// embedded in the public URL or any query string — mcp-server.js compares it +// constant-time on every request. 'use strict' @@ -21,33 +21,36 @@ const BEARER_BYTES = 32 const GRACE_MS = 2000 /** Give cloudflared this long to print its public URL before declaring it unavailable. */ const URL_TIMEOUT_MS = 30000 +/** Cap the accumulated stderr buffer so a chatty cloudflared can't grow it unbounded. */ +const STDERR_BUFFER_CAP = 64 * 1024 /** Matches the quick-tunnel URL cloudflared prints to its stderr. */ const TRYCLOUDFLARE_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i /** - * Start a cloudflared quick tunnel in front of `localUrl` and mint a mandatory - * bearer. Resolves once cloudflared prints its public URL; rejects with an - * UnavailableError if cloudflared is missing (ENOENT) or never prints a URL in - * time. The bearer is logged to stderr and returned — never put in the URL. + * Mint a high-entropy bearer for fronting the MCP face. 32 bytes => 256 bits, + * base64url-encoded so it's safe to carry verbatim in an Authorization header. + * + * @param {(n: number) => Buffer} [randomBytes] - injectable crypto.randomBytes. + * @returns {string} + */ +const generateBearer = (randomBytes = defaultRandomBytes) => randomBytes(BEARER_BYTES).toString('base64url') + +/** + * Start a cloudflared quick tunnel in front of `localUrl`, fronted by the + * caller-supplied mandatory bearer. Resolves once cloudflared prints its public + * URL; rejects with an UnavailableError if cloudflared is missing (ENOENT) or + * never prints a URL in time. The bearer is logged to stderr — never put in the + * URL. * * @param {Object} opts * @param {string} opts.localUrl - the local MCP face URL to tunnel to. + * @param {string} opts.bearer - the mandatory bearer fronting the face. * @param {Object} opts.logger - PII-safe logger. * @param {Function} [opts.spawn] - injectable child_process.spawn. - * @param {(n: number) => Buffer} [opts.randomBytes] - injectable crypto.randomBytes. * @param {number} [opts.urlTimeoutMs] - how long to wait for the public URL. * @returns {Promise<{ publicUrl: string, bearer: string, close(): Promise }>} */ -const startTunnel = ({ - localUrl, - logger, - spawn = defaultSpawn, - randomBytes = defaultRandomBytes, - urlTimeoutMs = URL_TIMEOUT_MS, -}) => { - // Mandatory bearer: there is no unauthenticated tunnel path. - const bearer = randomBytes(BEARER_BYTES).toString('base64url') - +const startTunnel = ({ localUrl, bearer, logger, spawn = defaultSpawn, urlTimeoutMs = URL_TIMEOUT_MS }) => { return new Promise((resolve, reject) => { const settled = { done: false } const child = spawn('cloudflared', ['tunnel', '--url', localUrl]) @@ -63,6 +66,7 @@ const startTunnel = ({ const close = () => new Promise((resolveClose) => { if (child.exitCode !== null || child.signalCode !== null) { + clearGrace() resolveClose() return } @@ -92,9 +96,13 @@ const startTunnel = ({ }) // cloudflared prints diagnostics — including the assigned URL — to stderr. + // Accumulate across chunks so a URL split over two `data` events still + // matches; cap the buffer so a chatty cloudflared can't grow it unbounded. + let stderrBuffer = '' child.stderr.on('data', (chunk) => { if (settled.done) return - const match = TRYCLOUDFLARE_RE.exec(chunk.toString('utf8')) + stderrBuffer = (stderrBuffer + chunk.toString('utf8')).slice(-STDERR_BUFFER_CAP) + const match = TRYCLOUDFLARE_RE.exec(stderrBuffer) if (!match) return settled.done = true clearTimeout(urlTimer) @@ -109,4 +117,4 @@ const startTunnel = ({ }) } -module.exports = { startTunnel, BEARER_BYTES, URL_TIMEOUT_MS } +module.exports = { startTunnel, generateBearer, BEARER_BYTES, URL_TIMEOUT_MS } diff --git a/thunderbolt-stdio-bridge/src/tunnel.test.js b/thunderbolt-stdio-bridge/src/tunnel.test.js index 87e6b87e6..f78d779e9 100644 --- a/thunderbolt-stdio-bridge/src/tunnel.test.js +++ b/thunderbolt-stdio-bridge/src/tunnel.test.js @@ -6,7 +6,7 @@ const { test, expect, mock } = require('bun:test') const { EventEmitter } = require('node:events') -const { startTunnel, BEARER_BYTES } = require('./tunnel') +const { startTunnel, generateBearer, BEARER_BYTES } = require('./tunnel') /** Capturing logger that records every event/text line written. */ const makeLogger = () => { @@ -34,19 +34,11 @@ const makeChild = () => { return child } -/** Deterministic randomBytes returning a fixed-byte buffer of length n. */ -const fixedRandomBytes = (fill) => (n) => Buffer.alloc(n, fill) - test('spawns cloudflared with `tunnel --url `', async () => { const logger = makeLogger() const child = makeChild() const spawn = mock(() => child) - const promise = startTunnel({ - localUrl: 'http://127.0.0.1:5000/mcp', - logger, - spawn, - randomBytes: fixedRandomBytes(1), - }) + const promise = startTunnel({ localUrl: 'http://127.0.0.1:5000/mcp', bearer: 'b', logger, spawn }) child.stderr.emit('data', Buffer.from('INF | https://happy-cloud-1.trycloudflare.com |\n')) await promise expect(spawn).toHaveBeenCalledWith('cloudflared', ['tunnel', '--url', 'http://127.0.0.1:5000/mcp']) @@ -55,44 +47,47 @@ test('spawns cloudflared with `tunnel --url `', async () => { test('parses the https://*.trycloudflare.com URL from stderr and resolves publicUrl', async () => { const logger = makeLogger() const child = makeChild() - const promise = startTunnel({ - localUrl: 'http://127.0.0.1:5000/mcp', - logger, - spawn: () => child, - randomBytes: fixedRandomBytes(2), - }) + const promise = startTunnel({ localUrl: 'http://127.0.0.1:5000/mcp', bearer: 'b', logger, spawn: () => child }) child.stderr.emit('data', Buffer.from('2024 INF Your quick Tunnel: https://abc-def-ghi.trycloudflare.com\n')) const { publicUrl } = await promise expect(publicUrl).toBe('https://abc-def-ghi.trycloudflare.com') }) -test('generates a high-entropy bearer (>=256 bits) and returns it', async () => { +test('the public URL is recognized when emitted SPLIT across two stderr chunks', async () => { const logger = makeLogger() const child = makeChild() + const promise = startTunnel({ localUrl: 'http://127.0.0.1:5000/mcp', bearer: 'b', logger, spawn: () => child }) + child.stderr.emit('data', Buffer.from('2024 INF Your quick Tunnel: https://abc-')) + child.stderr.emit('data', Buffer.from('def.trycloudflare.com\n')) + const { publicUrl } = await promise + expect(publicUrl).toBe('https://abc-def.trycloudflare.com') +}) + +test('generateBearer mints a high-entropy (>=256 bits) base64url bearer from randomBytes', () => { let requestedBytes = 0 const randomBytes = (n) => { requestedBytes = n return Buffer.alloc(n, 3) } - const promise = startTunnel({ localUrl: 'http://127.0.0.1:5000/mcp', logger, spawn: () => child, randomBytes }) - child.stderr.emit('data', Buffer.from('https://x.trycloudflare.com\n')) - const { bearer } = await promise + const bearer = generateBearer(randomBytes) expect(requestedBytes).toBe(BEARER_BYTES) expect(BEARER_BYTES * 8).toBeGreaterThanOrEqual(256) expect(bearer).toBe(Buffer.alloc(BEARER_BYTES, 3).toString('base64url')) }) +test('generateBearer produces distinct bearers for distinct randomBytes', () => { + const a = generateBearer((n) => Buffer.alloc(n, 1)) + const b = generateBearer((n) => Buffer.alloc(n, 2)) + expect(a).not.toBe(b) +}) + test('the bearer is logged to stderr and NEVER appears in publicUrl or any query string', async () => { const logger = makeLogger() const child = makeChild() - const promise = startTunnel({ - localUrl: 'http://127.0.0.1:5000/mcp', - logger, - spawn: () => child, - randomBytes: fixedRandomBytes(7), - }) + const bearer = 'super-secret-bearer-value' + const promise = startTunnel({ localUrl: 'http://127.0.0.1:5000/mcp', bearer, logger, spawn: () => child }) child.stderr.emit('data', Buffer.from('https://secret-tunnel.trycloudflare.com\n')) - const { publicUrl, bearer } = await promise + const { publicUrl } = await promise expect(publicUrl).not.toContain(bearer) expect(publicUrl).not.toContain('?') // The bearer must have been written to the (stderr) logger. @@ -103,12 +98,7 @@ test('the bearer is logged to stderr and NEVER appears in publicUrl or any query test('cloudflared ENOENT rejects with an unavailable error (→69) and a "not found" message', async () => { const logger = makeLogger() const child = makeChild() - const promise = startTunnel({ - localUrl: 'http://127.0.0.1:5000/mcp', - logger, - spawn: () => child, - randomBytes: fixedRandomBytes(1), - }) + const promise = startTunnel({ localUrl: 'http://127.0.0.1:5000/mcp', bearer: 'b', logger, spawn: () => child }) child.emit('error', Object.assign(new Error('spawn cloudflared ENOENT'), { code: 'ENOENT' })) await expect(promise).rejects.toMatchObject({ name: 'UnavailableError', code: 'ENOENT' }) await expect(promise).rejects.toThrow(/not found/) @@ -119,9 +109,9 @@ test('a timeout with no URL printed rejects with an unavailable error (→69) an const child = makeChild() const promise = startTunnel({ localUrl: 'http://127.0.0.1:5000/mcp', + bearer: 'b', logger, spawn: () => child, - randomBytes: fixedRandomBytes(1), urlTimeoutMs: 5, }) await expect(promise).rejects.toMatchObject({ name: 'UnavailableError' }) @@ -131,12 +121,7 @@ test('a timeout with no URL printed rejects with an unavailable error (→69) an test('close() SIGTERMs then SIGKILLs cloudflared (grace window): no orphan', async () => { const logger = makeLogger() const child = makeChild() - const promise = startTunnel({ - localUrl: 'http://127.0.0.1:5000/mcp', - logger, - spawn: () => child, - randomBytes: fixedRandomBytes(1), - }) + const promise = startTunnel({ localUrl: 'http://127.0.0.1:5000/mcp', bearer: 'b', logger, spawn: () => child }) child.stderr.emit('data', Buffer.from('https://x.trycloudflare.com\n')) const { close } = await promise const closing = close() @@ -150,12 +135,7 @@ test('close() SIGTERMs then SIGKILLs cloudflared (grace window): no orphan', asy test('close() is a no-op when cloudflared already exited', async () => { const logger = makeLogger() const child = makeChild() - const promise = startTunnel({ - localUrl: 'http://127.0.0.1:5000/mcp', - logger, - spawn: () => child, - randomBytes: fixedRandomBytes(1), - }) + const promise = startTunnel({ localUrl: 'http://127.0.0.1:5000/mcp', bearer: 'b', logger, spawn: () => child }) child.stderr.emit('data', Buffer.from('https://x.trycloudflare.com\n')) const { close } = await promise child.exitCode = 0 @@ -163,20 +143,11 @@ test('close() is a no-op when cloudflared already exited', async () => { expect(child.signals).not.toContain('SIGTERM') }) -test('two runs produce distinct bearers (randomBytes-driven)', async () => { +test('startTunnel returns the caller-supplied bearer verbatim', async () => { const logger = makeLogger() - const run = async (fill) => { - const child = makeChild() - const promise = startTunnel({ - localUrl: 'http://127.0.0.1:5000/mcp', - logger, - spawn: () => child, - randomBytes: fixedRandomBytes(fill), - }) - child.stderr.emit('data', Buffer.from('https://x.trycloudflare.com\n')) - return (await promise).bearer - } - const a = await run(1) - const b = await run(2) - expect(a).not.toBe(b) + const child = makeChild() + const promise = startTunnel({ localUrl: 'http://127.0.0.1:5000/mcp', bearer: 'caller-bearer', logger, spawn: () => child }) + child.stderr.emit('data', Buffer.from('https://x.trycloudflare.com\n')) + const { bearer } = await promise + expect(bearer).toBe('caller-bearer') }) diff --git a/thunderbolt-stdio-bridge/src/util.js b/thunderbolt-stdio-bridge/src/util.js index ccede6c63..4edb727ec 100644 --- a/thunderbolt-stdio-bridge/src/util.js +++ b/thunderbolt-stdio-bridge/src/util.js @@ -50,11 +50,13 @@ const isLoopbackHost = (host) => { } /** - * A resolve-once close latch shared by both faces. `settle` runs the bound + * A resolve-once close latch shared by both faces. `finishClose` runs the bound * resolver exactly once (later calls are no-ops); `close()` first sets the * resolver then triggers teardown, so the resolver fires when teardown - * completes. Centralizes the never-orphan teardown semantics so the two faces - * can't drift. + * completes. If teardown already finished (e.g. the child self-exited before + * `close()` was called), `setResolver` runs the resolver immediately so a later + * `close()` never hangs waiting on an already-settled latch. Centralizes the + * never-orphan teardown semantics so the two faces can't drift. * @returns {{ finishClose: () => void, setResolver: (fn: () => void) => void, settled: () => boolean }} */ const makeCloseLatch = () => { @@ -67,6 +69,10 @@ const makeCloseLatch = () => { if (resolveClose) resolveClose() }, setResolver: (fn) => { + if (settled) { + fn() + return + } resolveClose = fn }, settled: () => settled, From 91324acb9ef176f921e5c383a69b365f39497ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 25 Jun 2026 09:57:28 -0300 Subject: [PATCH 06/45] fix(THU-601): run installed bridge directly, scope app origin, verify installer checksum --- .github/workflows/stdio-bridge-release.yml | 11 +++++- src/acp/transports/is-loopback.test.ts | 12 ++++-- src/acp/transports/is-loopback.ts | 7 +++- .../agents/bridge-connect-dialog.test.tsx | 4 +- .../settings/agents/bridge-connect-dialog.tsx | 2 +- src/lib/agent-bridge-command.test.ts | 31 ++++++++++++--- src/lib/agent-bridge-command.ts | 39 ++++++++++++++++--- thunderbolt-stdio-bridge/install.sh | 36 ++++++++++++++++- 8 files changed, 120 insertions(+), 22 deletions(-) diff --git a/.github/workflows/stdio-bridge-release.yml b/.github/workflows/stdio-bridge-release.yml index bbc000bcd..a20423136 100644 --- a/.github/workflows/stdio-bridge-release.yml +++ b/.github/workflows/stdio-bridge-release.yml @@ -89,8 +89,15 @@ jobs: grep -q '"event":"mcp-listening"' mcp.log grep -q 'http://127.0.0.1:' mcp.log - - name: Publish bridge.cjs to the tag's GitHub Release + - name: Publish bridge.cjs (+ sha256) to the tag's GitHub Release if: startsWith(github.ref, 'refs/tags/stdio-bridge-v') env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release create "${GITHUB_REF_NAME}" dist/bridge.cjs --title "${GITHUB_REF_NAME}" --generate-notes + # Emit the checksum with a bare `bridge.cjs` basename (cd into dist) so the + # published .sha256 is in plain `shasum -a 256 -c` format; install.sh + # verifies the downloaded bundle against it before installing. + run: | + ( cd dist && shasum -a 256 bridge.cjs > bridge.cjs.sha256 ) + gh release create "${GITHUB_REF_NAME}" \ + dist/bridge.cjs dist/bridge.cjs.sha256 \ + --title "${GITHUB_REF_NAME}" --generate-notes diff --git a/src/acp/transports/is-loopback.test.ts b/src/acp/transports/is-loopback.test.ts index 30255aa27..1f578de7f 100644 --- a/src/acp/transports/is-loopback.test.ts +++ b/src/acp/transports/is-loopback.test.ts @@ -6,11 +6,14 @@ import { describe, expect, it } from 'bun:test' import { isLoopbackHost, isLoopbackUrl } from './is-loopback' describe('isLoopbackHost', () => { - it('classifies the four loopback hostnames', () => { + it('classifies the loopback hostnames', () => { expect(isLoopbackHost('127.0.0.1')).toBe(true) expect(isLoopbackHost('::1')).toBe(true) expect(isLoopbackHost('localhost')).toBe(true) - expect(isLoopbackHost('0.0.0.0')).toBe(true) + }) + + it('does not treat 0.0.0.0 as loopback (it is a bind address, not a connect target)', () => { + expect(isLoopbackHost('0.0.0.0')).toBe(false) }) it('is case-insensitive', () => { @@ -45,10 +48,13 @@ describe('isLoopbackUrl', () => { it('canonicalizes shorthand: ports, paths, and casing do not matter', () => { expect(isLoopbackUrl('http://localhost:3000/mcp')).toBe(true) - expect(isLoopbackUrl('ws://0.0.0.0:1234')).toBe(true) expect(isLoopbackUrl('http://LOCALHOST/x')).toBe(true) }) + it('does not treat a 0.0.0.0 URL as loopback (bind address, not a connect target)', () => { + expect(isLoopbackUrl('ws://0.0.0.0:1234')).toBe(false) + }) + it('classifies IPv6 loopback whether bracketed in the URL or not', () => { expect(isLoopbackUrl('ws://[::1]:8080')).toBe(true) expect(isLoopbackUrl('http://[::1]/mcp')).toBe(true) diff --git a/src/acp/transports/is-loopback.ts b/src/acp/transports/is-loopback.ts index a73a41c3f..5f9f58e21 100644 --- a/src/acp/transports/is-loopback.ts +++ b/src/acp/transports/is-loopback.ts @@ -15,8 +15,11 @@ /** Hostnames that resolve to the local machine, in canonical (bracket-free, * lowercase) form. `new URL().hostname` brackets IPv6 literals (`[::1]`), so - * `isLoopbackHost` strips those brackets before comparing against `::1`. */ -const loopbackHosts = new Set(['127.0.0.1', '::1', 'localhost', '0.0.0.0']) + * `isLoopbackHost` strips those brackets before comparing against `::1`. + * `0.0.0.0` is deliberately excluded: it is an all-interfaces *bind* address, + * not a valid *connect* target, and excluding it matches the bridge's own + * classifier (which accepts 127.0.0.0/8, ::1, localhost). */ +const loopbackHosts = new Set(['127.0.0.1', '::1', 'localhost']) /** * True when `host` names the local machine. Accepts a bare hostname (no diff --git a/src/components/settings/agents/bridge-connect-dialog.test.tsx b/src/components/settings/agents/bridge-connect-dialog.test.tsx index e1017b68a..9065ffbce 100644 --- a/src/components/settings/agents/bridge-connect-dialog.test.tsx +++ b/src/components/settings/agents/bridge-connect-dialog.test.tsx @@ -46,7 +46,7 @@ describe('BridgeConnectDialog — npx agent', () => { // Install command and the bridge run command both appear. expect(screen.getByText(/curl -fsSL/)).toBeInTheDocument() expect( - screen.getByText('npx thunderbolt-stdio-bridge --mode acp -- npx @google/gemini-cli@0.46.0 --acp'), + screen.getByText('thunderbolt-stdio-bridge --mode acp -- npx @google/gemini-cli@0.46.0 --acp'), ).toBeInTheDocument() // Step 3 directs the user to Add custom agent. expect(screen.getByText(/add custom agent/i)).toBeInTheDocument() @@ -60,7 +60,7 @@ describe('BridgeConnectDialog — npx agent', () => { }) expect(writeTextMock).toHaveBeenCalledWith( - 'npx thunderbolt-stdio-bridge --mode acp -- npx @google/gemini-cli@0.46.0 --acp', + 'thunderbolt-stdio-bridge --mode acp -- npx @google/gemini-cli@0.46.0 --acp', ) }) diff --git a/src/components/settings/agents/bridge-connect-dialog.tsx b/src/components/settings/agents/bridge-connect-dialog.tsx index d12d07d72..8ecf88a0d 100644 --- a/src/components/settings/agents/bridge-connect-dialog.tsx +++ b/src/components/settings/agents/bridge-connect-dialog.tsx @@ -74,7 +74,7 @@ const BinaryFallback = ({ entry }: { entry: RegistryEntry }) => { * agent's own docs instead. */ export const BridgeConnectDialog = ({ entry, open, onOpenChange }: BridgeConnectDialogProps) => { - const bridgeCommand = composeBridgeCommand(entry) + const bridgeCommand = composeBridgeCommand(entry, window.location.origin) return ( diff --git a/src/lib/agent-bridge-command.test.ts b/src/lib/agent-bridge-command.test.ts index 1ebcb22a3..353d9045e 100644 --- a/src/lib/agent-bridge-command.test.ts +++ b/src/lib/agent-bridge-command.test.ts @@ -51,21 +51,42 @@ describe('composeLaunchCommand', () => { }) describe('composeBridgeCommand', () => { - it('wraps an npx launch in the bridge command (--mode acp)', () => { + it('wraps an npx launch in the bridge command (--mode acp), invoking the bare on-PATH binary', () => { const entry = entryWith({ npx: { package: '@google/gemini-cli@0.46.0', args: ['--acp'] } }) - expect(composeBridgeCommand(entry)).toBe( - 'npx thunderbolt-stdio-bridge --mode acp -- npx @google/gemini-cli@0.46.0 --acp', - ) + const command = composeBridgeCommand(entry) + expect(command).toBe('thunderbolt-stdio-bridge --mode acp -- npx @google/gemini-cli@0.46.0 --acp') + expect(command?.startsWith('thunderbolt-stdio-bridge ')).toBe(true) + expect(command?.startsWith('npx')).toBe(false) }) it('wraps a uvx launch in the bridge command', () => { const entry = entryWith({ uvx: { package: 'fast-agent', args: ['acp'] } }) - expect(composeBridgeCommand(entry)).toBe('npx thunderbolt-stdio-bridge --mode acp -- uvx fast-agent acp') + expect(composeBridgeCommand(entry)).toBe('thunderbolt-stdio-bridge --mode acp -- uvx fast-agent acp') + }) + + it('adds --allow-origin for a non-loopback app origin (production web)', () => { + const entry = entryWith({ npx: { package: '@google/gemini-cli@0.46.0', args: ['--acp'] } }) + expect(composeBridgeCommand(entry, 'https://app.thunderbird.net')).toBe( + "thunderbolt-stdio-bridge --mode acp --allow-origin 'https://app.thunderbird.net' -- npx @google/gemini-cli@0.46.0 --acp", + ) + }) + + it('omits --allow-origin for a loopback app origin (default allowlist already accepts it)', () => { + const entry = entryWith({ npx: { package: '@google/gemini-cli@0.46.0', args: ['--acp'] } }) + const command = composeBridgeCommand(entry, 'http://localhost:1421') + expect(command).toBe('thunderbolt-stdio-bridge --mode acp -- npx @google/gemini-cli@0.46.0 --acp') + expect(command).not.toContain('--allow-origin') + }) + + it('omits --allow-origin when no origin is provided', () => { + const entry = entryWith({ npx: { package: '@google/gemini-cli@0.46.0', args: ['--acp'] } }) + expect(composeBridgeCommand(entry)).not.toContain('--allow-origin') }) it('returns null for a binary-only distribution (UI points at the agent site/repo)', () => { const entry = entryWith({ binary: { 'linux-x86_64': { cmd: './goose' } } }) expect(composeBridgeCommand(entry)).toBeNull() + expect(composeBridgeCommand(entry, 'https://app.thunderbird.net')).toBeNull() }) }) diff --git a/src/lib/agent-bridge-command.ts b/src/lib/agent-bridge-command.ts index 205594622..731834c4f 100644 --- a/src/lib/agent-bridge-command.ts +++ b/src/lib/agent-bridge-command.ts @@ -19,6 +19,7 @@ * the agent's own site/repo instead. */ +import { isLoopbackUrl } from '@/acp/transports/is-loopback' import type { RegistryEntry } from '@/types/registry' /** The command name the app's `install.sh` installs onto PATH. */ @@ -51,16 +52,44 @@ export const composeLaunchCommand = (entry: RegistryEntry): string | null => { /** The curl | bash command that installs the bridge onto the user's PATH. */ export const composeInstallCommand = (): string => installCommand +/** + * Whether a copied bridge command needs an explicit `--allow-origin`: only for a + * valid, non-loopback http(s) app origin (production web). Opaque origins (the + * literal `'null'`, `file:`, sandboxed frames) and loopback origins need nothing + * — the bridge's default allowlist already accepts loopback and absent Origins. + */ +const needsAllowOrigin = (origin: string | undefined): origin is string => { + if (!origin) { + return false + } + try { + const { protocol } = new URL(origin) + return (protocol === 'http:' || protocol === 'https:') && !isLoopbackUrl(origin) + } catch { + return false + } +} + /** * The full bridge command for an agent: - * `npx thunderbolt-stdio-bridge --mode acp -- `. Returns `null` when the - * agent only ships a binary distribution (no composable launch fragment), so the - * dialog can render its binary fallback instead. + * `thunderbolt-stdio-bridge --mode acp -- `. The bridge is the bare + * binary `install.sh` drops on PATH — invoke it directly (no `npx`, which would + * hit the registry since the bridge is never published to npm). Returns `null` + * when the agent only ships a binary distribution (no composable launch + * fragment), so the dialog can render its binary fallback instead. + * + * When `origin` is a non-loopback app origin (production web), the bridge's + * default loopback-only Origin allowlist would reject the browser's WS upgrade, + * so we add `--allow-origin ''`. A loopback origin (or none) needs + * nothing extra — the default allowlist already accepts loopback. + * + * The catalogue is ACP-only, so `--mode acp` is always correct here. */ -export const composeBridgeCommand = (entry: RegistryEntry): string | null => { +export const composeBridgeCommand = (entry: RegistryEntry, origin?: string): string | null => { const launch = composeLaunchCommand(entry) if (!launch) { return null } - return `npx ${bridgeBin} --mode acp -- ${launch}` + const allowOrigin = needsAllowOrigin(origin) ? `--allow-origin '${origin}' ` : '' + return `${bridgeBin} --mode acp ${allowOrigin}-- ${launch}` } diff --git a/thunderbolt-stdio-bridge/install.sh b/thunderbolt-stdio-bridge/install.sh index b7b85cc29..040e1e39d 100755 --- a/thunderbolt-stdio-bridge/install.sh +++ b/thunderbolt-stdio-bridge/install.sh @@ -24,7 +24,19 @@ command -v node >/dev/null 2>&1 || { echo "error: node is required (https://node command -v npm >/dev/null 2>&1 || { echo "error: npm is required (ships with node)" >&2; exit 1; } # Install next to npm/npx (the npm global bin), or honor an explicit override. -BIN_DIR="${THUNDERBOLT_BIN_DIR:-$(npm prefix -g 2>/dev/null)/bin}" +# Without an override, resolve the npm global prefix and refuse to proceed if it +# is empty — never fall back to a bare `/bin`, which would be wrong and unsafe. +if [ -n "${THUNDERBOLT_BIN_DIR:-}" ]; then + BIN_DIR="$THUNDERBOLT_BIN_DIR" +else + prefix="$(npm prefix -g 2>/dev/null)" + [ -n "$prefix" ] && [ -d "$prefix" ] || { + echo "error: could not resolve the npm global prefix (npm prefix -g)." >&2 + echo " set THUNDERBOLT_BIN_DIR=/path/to/bin and re-run." >&2 + exit 1 + } + BIN_DIR="$prefix/bin" +fi [ -d "$BIN_DIR" ] || mkdir -p "$BIN_DIR" # Resolve the version: explicit arg/env wins; otherwise read main's package.json @@ -36,13 +48,33 @@ if [ -z "$VERSION" ]; then [ -n "$VERSION" ] || { echo "error: could not resolve latest version" >&2; exit 1; } fi URL="https://github.com/$REPO/releases/download/stdio-bridge-v$VERSION/bridge.cjs" +SUM_URL="$URL.sha256" echo "Installing $CMD $VERSION -> $BIN_DIR/$CMD" # Download to a tmp file and move atomically — no half-written command on Ctrl-C. TMP=$(mktemp) -trap 'rm -f "$TMP"' EXIT +SUM_TMP=$(mktemp) +trap 'rm -f "$TMP" "$SUM_TMP"' EXIT curl -fL --progress-bar -o "$TMP" "$URL" + +# Verify the download against the Release's published SHA-256 before installing. +# Pick whichever checksum tool is present; the published file is in `shasum -c` +# format (` bridge.cjs`), so verify from $TMP's own directory under that +# basename. Abort on mismatch or a missing tool — never install unverified bytes. +curl -fsSL -o "$SUM_TMP" "$SUM_URL" +EXPECTED=$(awk '{print $1; exit}' "$SUM_TMP") +[ -n "$EXPECTED" ] || { echo "error: could not read published checksum" >&2; exit 1; } +if command -v shasum >/dev/null 2>&1; then + echo "$EXPECTED $TMP" | shasum -a 256 -c - >/dev/null 2>&1 \ + || { echo "error: checksum verification failed for bridge.cjs" >&2; exit 1; } +elif command -v sha256sum >/dev/null 2>&1; then + echo "$EXPECTED $TMP" | sha256sum -c - >/dev/null 2>&1 \ + || { echo "error: checksum verification failed for bridge.cjs" >&2; exit 1; } +else + echo "error: need shasum or sha256sum to verify the download" >&2; exit 1 +fi + chmod +x "$TMP" # The npm global bin may need root (system node / Homebrew) — single-file move. From 472ecd63fa59ad8417664a4b0dcb7ebc587d4caf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 25 Jun 2026 10:39:58 -0300 Subject: [PATCH 07/45] fix(THU-601): allow Mcp-Protocol-Version in MCP CORS preflight --- thunderbolt-stdio-bridge/src/mcp-server.js | 4 +++- thunderbolt-stdio-bridge/src/mcp-server.test.js | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/thunderbolt-stdio-bridge/src/mcp-server.js b/thunderbolt-stdio-bridge/src/mcp-server.js index 14da3ec6c..c7b94ad68 100644 --- a/thunderbolt-stdio-bridge/src/mcp-server.js +++ b/thunderbolt-stdio-bridge/src/mcp-server.js @@ -247,7 +247,9 @@ const startMcpFace = ({ res.setHeader('Vary', 'Origin') } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS') - res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Mcp-Session-Id, Accept') + // MCP Streamable HTTP clients send `Mcp-Protocol-Version` on every request + // after initialize; it must be allow-listed or the browser's preflight fails. + res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Accept') res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id') } diff --git a/thunderbolt-stdio-bridge/src/mcp-server.test.js b/thunderbolt-stdio-bridge/src/mcp-server.test.js index c78878963..91dce2e49 100644 --- a/thunderbolt-stdio-bridge/src/mcp-server.test.js +++ b/thunderbolt-stdio-bridge/src/mcp-server.test.js @@ -226,6 +226,9 @@ test('CORS preflight (OPTIONS) returns 204 and allow-origin per allowlist', asyn await fireRequest(server, req, res) expect(res.statusCode).toBe(204) expect(res.headers['Access-Control-Allow-Origin']).toBe('http://localhost:5173') + // The MCP Streamable HTTP client sends Mcp-Protocol-Version on every request + // after initialize; it MUST be allow-listed or the browser preflight fails. + expect(res.headers['Access-Control-Allow-Headers']).toContain('Mcp-Protocol-Version') }) test('--allow-any-origin echoes * for any Origin', async () => { From f02f09f34dcc7daf6e0d7db8cf4a06fcb98360b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 25 Jun 2026 12:16:58 -0300 Subject: [PATCH 08/45] fix(THU-601): drop duplicate shebang so the bundled bridge.cjs runs --- thunderbolt-stdio-bridge/scripts/build-cli.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/thunderbolt-stdio-bridge/scripts/build-cli.mjs b/thunderbolt-stdio-bridge/scripts/build-cli.mjs index 96fcf24fd..2b2e5641a 100644 --- a/thunderbolt-stdio-bridge/scripts/build-cli.mjs +++ b/thunderbolt-stdio-bridge/scripts/build-cli.mjs @@ -30,7 +30,9 @@ await build({ // them external so the bundle never hard-requires a compiled addon. external: ['bufferutil', 'utf-8-validate'], define: { __BRIDGE_VERSION__: JSON.stringify(pkg.version) }, - banner: { js: '#!/usr/bin/env node' }, + // No shebang banner: esbuild preserves the entry's own `#!/usr/bin/env node` + // (bin/cli.js line 1) atop the bundle. Adding a banner shebang too yields a + // duplicate second `#!` line, which Node rejects as a syntax error. }) await chmod(outfile, 0o755) From 8af4c9e8b7f78114f4eb3cfac8a7fcabe54b7c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 25 Jun 2026 12:17:03 -0300 Subject: [PATCH 09/45] chore: gitignore .playwright-mcp scratch snapshots --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 007c8c6f1..6cdb45cf5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Playwright MCP scratch (accessibility-tree snapshots can capture typed secrets) +.playwright-mcp/ + # Logs logs *.log From 3b2b80d1e9d4a54236143789167427d5e0221f66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 25 Jun 2026 12:34:29 -0300 Subject: [PATCH 10/45] fix(THU-601): multiplex MCP sessions so concurrent clients share one stdio child --- .../src/mcp-multiplexer.js | 215 ++++++++++++++++++ .../src/mcp-multiplexer.test.js | 202 ++++++++++++++++ .../src/mcp-server.integration.test.js | 47 ++++ thunderbolt-stdio-bridge/src/mcp-server.js | 116 +++++----- .../src/mcp-server.test.js | 114 +++++++--- 5 files changed, 617 insertions(+), 77 deletions(-) create mode 100644 thunderbolt-stdio-bridge/src/mcp-multiplexer.js create mode 100644 thunderbolt-stdio-bridge/src/mcp-multiplexer.test.js diff --git a/thunderbolt-stdio-bridge/src/mcp-multiplexer.js b/thunderbolt-stdio-bridge/src/mcp-multiplexer.js new file mode 100644 index 000000000..ae298de35 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/mcp-multiplexer.js @@ -0,0 +1,215 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// The MCP multiplexer: fans many per-request StreamableHTTP transports onto the +// ONE stdio child. It owns the child's initialize cache, the process-global +// request-id remap, and the live-transport registry. The HTTP face owns binding, +// security, and teardown; this owns everything between a client message +// (transport.onmessage) and the child, and between the child's stdout and the +// owning transport (transport.send). +// +// Why a multiplexer at all: a StreamableHTTPServerTransport is single-session and +// rejects a second `initialize` POST with -32600 before onmessage fires, so a +// shared transport kills every connection after the first. We give each HTTP +// request its own STATELESS transport (the SDK forbids reusing one across +// requests) and reconcile their many initialize/id streams onto the single child +// here. + +'use strict' + +const { classifyFrame } = require('./log') +const { wsToFrame } = require('./relay') + +/** + * Build the multiplexer. + * + * @param {Object} opts + * @param {(frame: string) => void} opts.writeChild - write one NDJSON frame to the + * child's stdin (the supervisor's writeStdin). + * @param {{ warn: Function }} opts.logger - PII-safe logger. + * @returns {{ + * createTransport(TransportClass: Function): object, + * releaseTransport(transport: object): void, + * onChildMessage(message: object): void, + * closeAll(): void, + * }} + */ +const createMultiplexer = ({ writeChild, logger }) => { + /** Live per-request transports, for broadcast + teardown. */ + const transports = new Set() + + /** + * Outstanding child requests keyed by the process-global id we assigned. Maps + * back to the owning transport and the client's original id so the child's + * response routes home with the id the client expects. + * @type {Map} + */ + const pending = new Map() + + /** Monotonic counter backing the process-global request ids. */ + let seq = 0 + /** Prefix-tagged global id; the `b:` namespace can never collide with a client id. */ + const nextGlobalId = () => `b:${seq++}` + + /** + * The child's negotiated initialize result (capabilities/serverInfo/ + * protocolVersion), captured from the first initialize response. Null until the + * child has answered. Every later client initialize is answered from this. + * @type {object|null} + */ + let childInitResult = null + /** True once the first initialize has been forwarded to the child. */ + let initForwarded = false + /** True once `notifications/initialized` has been forwarded once. */ + let initializedNotified = false + /** The global id under which the first initialize was forwarded to the child. */ + let initGlobalId = null + /** + * Initialize requests that arrived before the child answered the first one. + * Each is answered from the cached result the moment it lands — never forwarded. + * @type {Array<{ transport: object, clientId: string|number }>} + */ + const initWaiters = [] + + /** Send one frame to a transport, swallowing a benign disconnected-client reject. */ + const sendTo = (transport, message) => { + Promise.resolve(transport.send(message)).catch(() => logger.warn('drop-child-frame', classifyFrame(message))) + } + + /** Forward one client message to the child as NDJSON, dropping unserializable frames. */ + const forwardToChild = (message) => { + try { + writeChild(wsToFrame(JSON.stringify(message))) + } catch { + logger.warn('drop-http-frame', classifyFrame(message)) + } + } + + /** + * Answer a client initialize from the cached child result, re-stamped with the + * client's request id (the negotiated protocolVersion/capabilities are reused + * verbatim from the child's reply). + */ + const answerInitFromCache = (transport, clientId) => { + sendTo(transport, { jsonrpc: '2.0', id: clientId, result: childInitResult }) + } + + /** + * Handle a client `initialize`. The first one is forwarded to the child under a + * global id and the requester is queued; concurrent ones that arrive before the + * child answers are queued too (never forwarded); once cached, every initialize + * is answered directly. The child therefore sees exactly one initialize, ever. + */ + const handleInitialize = (transport, message) => { + if (childInitResult !== null) { + answerInitFromCache(transport, message.id) + return + } + initWaiters.push({ transport, clientId: message.id }) + if (initForwarded) return // a forward is already in flight; just wait for it + initForwarded = true + initGlobalId = nextGlobalId() + forwardToChild({ ...message, id: initGlobalId }) + } + + /** + * Route the child's reply to the forwarded initialize. A success caches the + * result and answers every queued waiter (the first plus any that raced in + * behind it). An error is relayed to every waiter (re-stamped with their client + * id) and the init state is RESET so the next client initialize re-forwards to + * the child — otherwise a single child-side init failure would wedge every + * future connection. + * @returns {boolean} true if the message was the awaited initialize reply. + */ + const captureInitReply = (message) => { + if (childInitResult !== null || initGlobalId === null || message.id !== initGlobalId) return false + if (message.result) { + childInitResult = message.result + for (const waiter of initWaiters.splice(0)) answerInitFromCache(waiter.transport, waiter.clientId) + return true + } + // Error reply: relay to waiters and reset so a later initialize can retry. + for (const waiter of initWaiters.splice(0)) { + sendTo(waiter.transport, { ...message, id: waiter.clientId }) + } + initForwarded = false + initGlobalId = null + return true + } + + return { + /** + * Create a stateless per-request transport, wire its onmessage into the mux, + * and register it live. The HTTP face hands the request to its handleRequest. + */ + createTransport(TransportClass) { + const transport = new TransportClass({ sessionIdGenerator: undefined }) + transport.onmessage = (message) => { + // initialize: serve from cache / forward exactly once. + if (message.method === 'initialize') { + handleInitialize(transport, message) + return + } + // notifications/initialized: forward exactly once; swallow the rest. + if (message.method === 'notifications/initialized') { + if (initializedNotified) return + initializedNotified = true + forwardToChild(message) + return + } + // A request (has an id + method): remap the id to a global id so this + // client's id can't collide with another's, and remember where to route + // the child's response. + if (message.id !== undefined && message.id !== null && typeof message.method === 'string') { + const globalId = nextGlobalId() + pending.set(globalId, { transport, clientId: message.id }) + forwardToChild({ ...message, id: globalId }) + return + } + // A bare notification / response without a routable id: forward as-is. + forwardToChild(message) + } + transports.add(transport) + return transport + }, + + /** Drop a transport once its request settles; cancel any pending it owned. */ + releaseTransport(transport) { + transports.delete(transport) + for (const [globalId, route] of pending) { + if (route.transport === transport) pending.delete(globalId) + } + }, + + /** Route one parsed child-stdout message back to the right HTTP client(s). */ + onChildMessage(message) { + if (captureInitReply(message)) return + // A response to a forwarded request: route it home with the client's id. + if (message.id !== undefined && message.id !== null && pending.has(message.id)) { + const { transport, clientId } = pending.get(message.id) + pending.delete(message.id) + sendTo(transport, { ...message, id: clientId }) + return + } + // An id-less notification (e.g. notifications/tools/list_changed): broadcast + // to every live transport so server->client notifications still reach clients. + if (message.id === undefined || message.id === null) { + for (const transport of transports) sendTo(transport, message) + return + } + // A response whose id we don't recognize (e.g. the child's reply to an + // initialize error, or a stale id after release): nothing to route it to. + logger.warn('drop-child-frame', classifyFrame(message)) + }, + + /** Close every live transport (teardown). */ + closeAll() { + for (const transport of transports) transport.close() + transports.clear() + pending.clear() + }, + } +} + +module.exports = { createMultiplexer } diff --git a/thunderbolt-stdio-bridge/src/mcp-multiplexer.test.js b/thunderbolt-stdio-bridge/src/mcp-multiplexer.test.js new file mode 100644 index 000000000..731fc0c93 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/mcp-multiplexer.test.js @@ -0,0 +1,202 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict' + +const { test, expect, mock } = require('bun:test') +const { createMultiplexer } = require('./mcp-multiplexer') + +/** A silent PII-safe logger spy. */ +const makeLogger = () => ({ + info: mock(() => {}), + warn: mock(() => {}), + error: mock(() => {}), + banner: mock(() => {}), +}) + +/** A fake per-request transport: captures sent frames; onmessage is wired by the mux. */ +const makeTransport = () => ({ + onmessage: null, + send: mock(() => Promise.resolve()), + close: mock(() => Promise.resolve()), +}) + +/** A fake StreamableHTTPServerTransport class the mux instantiates per request. */ +const TransportClass = class { + constructor() { + return makeTransport() + } +} + +/** Build a mux whose child writes are captured as parsed frames. */ +const makeMux = () => { + const logger = makeLogger() + const written = [] + const mux = createMultiplexer({ + writeChild: (frame) => written.push(JSON.parse(frame.replace(/\n$/, ''))), + logger, + }) + return { mux, written, logger } +} + +/** A standard client initialize request. */ +const initRequest = (id) => ({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '0' } }, +}) + +/** The child's initialize result payload (capabilities/serverInfo/protocolVersion). */ +const childInitResult = { + protocolVersion: '2025-06-18', + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 'everything', version: '2.0.0' }, +} + +test('the FIRST client initialize is forwarded to the child exactly once (id remapped)', () => { + const { mux, written } = makeMux() + const t = mux.createTransport(TransportClass) + t.onmessage(initRequest(1)) + expect(written).toHaveLength(1) + expect(written[0].method).toBe('initialize') + expect(written[0].id).not.toBe(1) // remapped to a global id + expect(typeof written[0].id).toBe('string') +}) + +test('the child initialize result is cached and answered to the first client with its id', () => { + const { mux, written } = makeMux() + const t = mux.createTransport(TransportClass) + t.onmessage(initRequest(1)) + const globalId = written[0].id + mux.onChildMessage({ jsonrpc: '2.0', id: globalId, result: childInitResult }) + expect(t.send).toHaveBeenCalledTimes(1) + expect(t.send.mock.calls[0][0]).toEqual({ jsonrpc: '2.0', id: 1, result: childInitResult }) +}) + +test('a SECOND client initialize is NOT forwarded to the child and is answered from cache with its own id', () => { + const { mux, written } = makeMux() + const t1 = mux.createTransport(TransportClass) + t1.onmessage(initRequest(1)) + const globalId = written[0].id + mux.onChildMessage({ jsonrpc: '2.0', id: globalId, result: childInitResult }) + + // Second client (a fresh transport) initializes: the child must NOT see it. + const t2 = mux.createTransport(TransportClass) + t2.onmessage(initRequest(99)) + expect(written).toHaveLength(1) // STILL just the first forwarded initialize + expect(t2.send).toHaveBeenCalledTimes(1) + expect(t2.send.mock.calls[0][0]).toEqual({ jsonrpc: '2.0', id: 99, result: childInitResult }) +}) + +test('a concurrent initialize that races in BEFORE the child answers is queued and answered from cache (not forwarded)', () => { + const { mux, written } = makeMux() + const t1 = mux.createTransport(TransportClass) + const t2 = mux.createTransport(TransportClass) + // Both initialize before the child has replied to the first. + t1.onmessage(initRequest(10)) + t2.onmessage(initRequest(20)) + // Only ONE initialize reached the child. + expect(written).toHaveLength(1) + const globalId = written[0].id + // Neither has been answered yet. + expect(t1.send).not.toHaveBeenCalled() + expect(t2.send).not.toHaveBeenCalled() + // The child answers; both waiters are served from the cached result, each with + // its own client id. + mux.onChildMessage({ jsonrpc: '2.0', id: globalId, result: childInitResult }) + expect(t1.send).toHaveBeenCalledTimes(1) + expect(t1.send.mock.calls[0][0]).toEqual({ jsonrpc: '2.0', id: 10, result: childInitResult }) + expect(t2.send).toHaveBeenCalledTimes(1) + expect(t2.send.mock.calls[0][0]).toEqual({ jsonrpc: '2.0', id: 20, result: childInitResult }) +}) + +test('the FIRST notifications/initialized is forwarded; duplicates are swallowed', () => { + const { mux, written } = makeMux() + const t1 = mux.createTransport(TransportClass) + const t2 = mux.createTransport(TransportClass) + t1.onmessage({ jsonrpc: '2.0', method: 'notifications/initialized' }) + t2.onmessage({ jsonrpc: '2.0', method: 'notifications/initialized' }) + // Only the first reaches the child (server-everything would otherwise be confused). + const initializedFrames = written.filter((f) => f.method === 'notifications/initialized') + expect(initializedFrames).toHaveLength(1) +}) + +test('a client request id is remapped to a global id and the response routes home to the right transport', () => { + const { mux, written } = makeMux() + const t1 = mux.createTransport(TransportClass) + const t2 = mux.createTransport(TransportClass) + // Both clients use the SAME local id (1) — the classic collision the remap fixes. + t1.onmessage({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }) + t2.onmessage({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }) + expect(written).toHaveLength(2) + const [g1, g2] = written.map((f) => f.id) + expect(g1).not.toBe(g2) // distinct global ids despite identical client ids + + // The child answers each global id; each response routes to ITS transport as id 1. + mux.onChildMessage({ jsonrpc: '2.0', id: g2, result: { tools: ['b'] } }) + mux.onChildMessage({ jsonrpc: '2.0', id: g1, result: { tools: ['a'] } }) + expect(t1.send).toHaveBeenCalledTimes(1) + expect(t1.send.mock.calls[0][0]).toEqual({ jsonrpc: '2.0', id: 1, result: { tools: ['a'] } }) + expect(t2.send).toHaveBeenCalledTimes(1) + expect(t2.send.mock.calls[0][0]).toEqual({ jsonrpc: '2.0', id: 1, result: { tools: ['b'] } }) +}) + +test('an id-less child notification is broadcast to every LIVE transport', () => { + const { mux } = makeMux() + const t1 = mux.createTransport(TransportClass) + const t2 = mux.createTransport(TransportClass) + mux.onChildMessage({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' }) + expect(t1.send).toHaveBeenCalledTimes(1) + expect(t2.send).toHaveBeenCalledTimes(1) + expect(t1.send.mock.calls[0][0]).toEqual({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' }) +}) + +test('a released transport no longer receives broadcasts and its pending route is cancelled', () => { + const { mux, written } = makeMux() + const t1 = mux.createTransport(TransportClass) + const t2 = mux.createTransport(TransportClass) + t1.onmessage({ jsonrpc: '2.0', id: 5, method: 'tools/list', params: {} }) + const g1 = written[0].id + mux.releaseTransport(t1) + // Broadcast reaches only t2 now. + mux.onChildMessage({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' }) + expect(t1.send).not.toHaveBeenCalled() + expect(t2.send).toHaveBeenCalledTimes(1) + // A late response for t1's cancelled pending route is dropped (no throw, logged). + mux.onChildMessage({ jsonrpc: '2.0', id: g1, result: {} }) + expect(t1.send).not.toHaveBeenCalled() +}) + +test('a child error reply to the forwarded initialize is relayed to the waiter and resets so a later initialize retries', () => { + const { mux, written } = makeMux() + const t = mux.createTransport(TransportClass) + t.onmessage(initRequest(1)) + const globalId = written[0].id + // The child rejects the initialize with an error (no result): it must NOT be + // cached. The error is relayed to the waiting client (re-stamped with its id). + mux.onChildMessage({ jsonrpc: '2.0', id: globalId, error: { code: -32000, message: 'boom' } }) + expect(t.send).toHaveBeenCalledTimes(1) + expect(t.send.mock.calls[0][0]).toEqual({ jsonrpc: '2.0', id: 1, error: { code: -32000, message: 'boom' } }) + + // The init state reset, so a subsequent initialize re-forwards to the child + // (the cache never populated) — a single failure can't wedge every future client. + const t2 = mux.createTransport(TransportClass) + t2.onmessage(initRequest(2)) + expect(written).toHaveLength(2) + expect(written[1].method).toBe('initialize') +}) + +test('closeAll closes every live transport and clears pending routes', () => { + const { mux, written } = makeMux() + const t1 = mux.createTransport(TransportClass) + const t2 = mux.createTransport(TransportClass) + t1.onmessage({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }) + mux.closeAll() + expect(t1.close).toHaveBeenCalledTimes(1) + expect(t2.close).toHaveBeenCalledTimes(1) + // A late response after closeAll routes nowhere (pending cleared) — no throw. + const g1 = written[0].id + expect(() => mux.onChildMessage({ jsonrpc: '2.0', id: g1, result: {} })).not.toThrow() +}) diff --git a/thunderbolt-stdio-bridge/src/mcp-server.integration.test.js b/thunderbolt-stdio-bridge/src/mcp-server.integration.test.js index 98eb00dd9..d92bae082 100644 --- a/thunderbolt-stdio-bridge/src/mcp-server.integration.test.js +++ b/thunderbolt-stdio-bridge/src/mcp-server.integration.test.js @@ -86,6 +86,53 @@ describe.skipIf(unavailable)('mcp-server integration (real server-everything)', const text = (result.content ?? []).map((c) => c.text ?? '').join('') expect(text).toContain('hello bridge') }) + + // The multiplexing proof: the Thunderbolt app opens several MCP connections + // (a Test-Connection probe, the persistent provider connection, reconnects). + // The OLD single-session transport rejected the SECOND initialize with + // "Server already initialized", killing the live connection. Here TWO clients + // connect to the SAME face URL sequentially (and a THIRD after the first + // closes), all sharing the ONE child (initialized exactly once) — every one + // must initialize, list the full tool set, and round-trip a tool call. + test('TWO concurrent clients both initialize, list tools, and call echo (multiplexed onto one child)', async () => { + const open = async (name) => { + const c = new Client({ name, version: '0.0.0' }) + await c.connect(new StreamableHTTPClientTransport(new URL(face.url))) + return c + } + + // The first client (`client`) is already connected from beforeAll. Open a + // second to the same URL — under the OLD code this initialize was rejected. + const second = await open('integration-second') + try { + expect(client.getServerVersion()).toBeTruthy() + expect(second.getServerVersion()).toBeTruthy() + + const first = await client.listTools() + const secondList = await second.listTools() + expect(first.tools).toHaveLength(13) + expect(secondList.tools).toHaveLength(13) + + const e1 = await client.callTool({ name: 'echo', arguments: { message: 'from-first' } }) + const e2 = await second.callTool({ name: 'echo', arguments: { message: 'from-second' } }) + expect((e1.content ?? []).map((c) => c.text ?? '').join('')).toContain('from-first') + expect((e2.content ?? []).map((c) => c.text ?? '').join('')).toContain('from-second') + + // A THIRD client after the second closes: the child stays initialized once. + await second.close() + const third = await open('integration-third') + try { + const thirdList = await third.listTools() + expect(thirdList.tools).toHaveLength(13) + const e3 = await third.callTool({ name: 'echo', arguments: { message: 'from-third' } }) + expect((e3.content ?? []).map((c) => c.text ?? '').join('')).toContain('from-third') + } finally { + await third.close().catch(() => {}) + } + } finally { + await second.close().catch(() => {}) + } + }) }) test.skipIf(!unavailable)('skips gracefully when server-everything is unavailable', () => { diff --git a/thunderbolt-stdio-bridge/src/mcp-server.js b/thunderbolt-stdio-bridge/src/mcp-server.js index c7b94ad68..d062fd29b 100644 --- a/thunderbolt-stdio-bridge/src/mcp-server.js +++ b/thunderbolt-stdio-bridge/src/mcp-server.js @@ -2,11 +2,25 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. -// The MCP Streamable HTTP face. Stands up a bare @modelcontextprotocol/sdk -// StreamableHTTPServerTransport behind a minimal http.createServer on host:port -// (default 127.0.0.1) and bridges it to the spawned stdio MCP child: HTTP-side -// JSON-RPC messages are written to the child as NDJSON (relay.wsToFrame) and the -// child's NDJSON stdout lines are pushed back to HTTP clients (transport.send). +// The MCP Streamable HTTP face. Stands up a minimal http.createServer on +// host:port (default 127.0.0.1) and bridges many HTTP MCP clients onto the ONE +// spawned stdio MCP child. +// +// MULTIPLEXING. A StreamableHTTPServerTransport is STATEFUL and single-session: +// the SDK rejects a second `initialize` POST with -32600 "Server already +// initialized" before onmessage ever fires (see webStandardStreamableHttp.js). +// The Thunderbolt app opens several MCP connections (a Test-Connection probe, +// the persistent provider connection, reconnects), so one shared transport kills +// every connection after the first. The fix: one STATELESS transport per HTTP +// request (the SDK forbids reusing a stateless transport across requests) plus a +// bridge-owned multiplexer that (a) forwards the FIRST initialize to the child, +// captures its result, and answers every later client initialize FROM CACHE +// without touching the child (the child — server-everything — rejects a second +// initialize too); (b) remaps each client request id to a process-global id so +// concurrent clients' colliding ids route back to the right transport; (c) +// forwards `notifications/initialized` exactly once; (d) broadcasts the child's +// id-less notifications to every live transport. +// // Enforces bearer-before-route, CORS per the Origin allowlist, a request body // cap, and deterministic never-orphan teardown. Prints the // `http://127.0.0.1:PORT/mcp` banner to stderr once listening. @@ -15,13 +29,13 @@ const { createServer: defaultCreateServer } = require('node:http') const { createHash, timingSafeEqual } = require('node:crypto') -const { randomUUID } = require('node:crypto') const { StreamableHTTPServerTransport: DefaultStreamableHTTPServerTransport, } = require('@modelcontextprotocol/sdk/server/streamableHttp.js') const { UnavailableError } = require('./errors') const { buildOriginAllowlist, classifyFrame, safeClassifyFrame } = require('./log') const { createNdjsonReader, wsToFrame } = require('./relay') +const { createMultiplexer } = require('./mcp-multiplexer') const { superviseChild: defaultSuperviseChild } = require('./child') const { formatHostForUrl, makeCloseLatch } = require('./util') @@ -133,9 +147,8 @@ const readBody = (req, cap) => /** * Start the MCP Streamable HTTP face: bind, spawn the child MCP stdio server, - * connect a bare StreamableHTTPServerTransport, and relay JSON-RPC between the - * HTTP face and the child's NDJSON stdio. Bearer-before-route, CORS, body cap, - * deterministic teardown, never-orphan. + * and multiplex many HTTP MCP clients onto the single child's NDJSON stdio. + * Bearer-before-route, CORS, body cap, deterministic teardown, never-orphan. * * @param {Object} opts * @param {string[]} opts.launch - child launch argv. @@ -174,24 +187,21 @@ const startMcpFace = ({ return new Promise((resolve, reject) => { const latch = makeCloseLatch() - const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }) - - // HTTP -> child: every JSON-RPC message the transport surfaces is written to - // the child's stdin as one NDJSON line. Malformed (unserializable) frames are - // dropped and logged by method/id only — the raw frame is never logged. - transport.onmessage = (message) => { - try { - supervisor.writeStdin(wsToFrame(JSON.stringify(message))) - } catch { - logger.warn('drop-http-frame', classifyFrame(message)) - } - } - // child stdout NDJSON -> HTTP: each complete line is parsed and pushed to the - // transport, which routes it to the correct pending HTTP/SSE response. The - // SDK's send can reject when the target client has disconnected — a benign, - // per-frame condition, not a bridge fault — so swallow it (logged PII-safe) - // instead of letting an unhandled rejection reach the never-orphan backstop. + // The bridge-owned multiplexer: owns the single child's initialize cache, the + // global request-id remap, and the live-transport registry. `writeChild` + // reads `supervisor` lazily — it's declared below but always assigned before + // any frame can flow (the first child write happens on an HTTP request, long + // after this synchronous setup completes). + const mux = createMultiplexer({ + writeChild: (frame) => supervisor.writeStdin(frame), + logger, + }) + + // child stdout NDJSON -> HTTP: each complete line is parsed and routed by the + // multiplexer to the transport that owns its id (or, for the captured init + // result, to every queued initialize; for id-less notifications, broadcast to + // every live transport). A malformed line is dropped + logged PII-safe. const reader = createNdjsonReader((line) => { const message = (() => { try { @@ -201,23 +211,19 @@ const startMcpFace = ({ return null } })() - if (message) { - Promise.resolve(transport.send(message)).catch(() => - logger.warn('drop-child-frame', classifyFrame(message)), - ) - } + if (message) mux.onChildMessage(message) }) const server = createServer() const finishClose = latch.finishClose - // Close the transport then the http server, settling the close latch once the - // server's callback fires. Lingering keep-alive/stalled sockets are force-closed - // so finishClose fires promptly (server.close otherwise waits indefinitely). - // Shared by child-exit teardown and the resolved close(). + // Close every live transport then the http server, settling the close latch + // once the server's callback fires. Lingering keep-alive/stalled sockets are + // force-closed so finishClose fires promptly (server.close otherwise waits + // indefinitely). Shared by child-exit teardown and the resolved close(). const shutdownHttp = () => { - transport.close() + mux.closeAll() server.close(finishClose) if (typeof server.closeAllConnections === 'function') server.closeAllConnections() } @@ -249,22 +255,30 @@ const startMcpFace = ({ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS') // MCP Streamable HTTP clients send `Mcp-Protocol-Version` on every request // after initialize; it must be allow-listed or the browser's preflight fails. - res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Accept') + res.setHeader( + 'Access-Control-Allow-Headers', + 'Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Accept', + ) res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id') } - // Hand a request to the transport, swallowing a benign rejection (a client - // that disconnected mid-response). The SDK can reject from handleRequest the - // same way it can from send; a stale client is not a fatal bridge fault, so - // it must never reach the never-orphan backstop. Reply 500 only if the socket - // is still writable; otherwise the response is already gone — just drop. The - // caller-supplied thunk runs handleRequest with the right arity (the POST path - // always passes its parsed body, possibly undefined; GET/DELETE pass none). - const dispatch = (res, handle) => { - Promise.resolve(handle()).catch((err) => { - logger.warn('drop-http-frame', { errorCode: err && err.code }) - if (res.writable && !res.writableEnded && !res.headersSent) replyStatus(res, 500) - }) + // Hand a request to a fresh per-request transport, swallowing a benign + // rejection (a client that disconnected mid-response). The SDK can reject from + // handleRequest the same way it can from send; a stale client is not a fatal + // bridge fault, so it must never reach the never-orphan backstop. Reply 500 + // only if the socket is still writable; otherwise the response is already gone + // — just drop. The caller-supplied thunk runs handleRequest with the right + // arity (the POST path always passes its parsed body, possibly undefined; + // GET/DELETE pass none). The transport is registered for the lifetime of the + // request and unregistered once handleRequest settles. + const dispatch = (res, makeHandle) => { + const transport = mux.createTransport(StreamableHTTPServerTransport) + Promise.resolve(makeHandle(transport)) + .catch((err) => { + logger.warn('drop-http-frame', { errorCode: err && err.code }) + if (res.writable && !res.writableEnded && !res.headersSent) replyStatus(res, 500) + }) + .finally(() => mux.releaseTransport(transport)) } server.on('request', async (req, res) => { @@ -313,11 +327,11 @@ const startMcpFace = ({ replyStatus(res, 400) return } - dispatch(res, () => transport.handleRequest(req, res, parsed)) + dispatch(res, (transport) => transport.handleRequest(req, res, parsed)) return } - dispatch(res, () => transport.handleRequest(req, res)) + dispatch(res, (transport) => transport.handleRequest(req, res)) }) server.on('error', (err) => { diff --git a/thunderbolt-stdio-bridge/src/mcp-server.test.js b/thunderbolt-stdio-bridge/src/mcp-server.test.js index 91dce2e49..d36b22809 100644 --- a/thunderbolt-stdio-bridge/src/mcp-server.test.js +++ b/thunderbolt-stdio-bridge/src/mcp-server.test.js @@ -40,7 +40,7 @@ const makeFakeServer = () => { return server } -/** Fake StreamableHTTPServerTransport. */ +/** Fake StreamableHTTPServerTransport (one per HTTP request under the multiplexer). */ const makeFakeTransport = () => { const transport = { onmessage: null, @@ -80,16 +80,34 @@ const makeFakeSupervisor = () => { /** Build the injectable deps + capture the wired supervise hooks. */ const makeHarness = (overrides = {}) => { const server = makeFakeServer() - const transport = makeFakeTransport() const { supervisor, calls } = makeFakeSupervisor() const hooks = {} + // The multiplexer creates one stateless transport per HTTP request. These + // HTTP-face tests fire a single request each and exercise the shell (routing, + // security, relay wiring), so the fake class returns ONE shared instance: a + // test can drive its onmessage and assert its send/handleRequest/close exactly + // as before. The per-request fan-out (id remap, init cache, broadcast) is + // covered against the real multiplexer in mcp-multiplexer.test.js and end-to-end + // in mcp-server.integration.test.js. + const transport = makeFakeTransport() + const transports = [] + // When set, the next dispatched request's handleRequest never settles, modelling + // a long-lived open stream so its transport stays registered (LIVE) through + // teardown — used to assert closeAll() closes live transports. Auto-clears. + const hold = { next: false } + class FakeTransport { + constructor() { + if (hold.next) { + hold.next = false + transport.handleRequest = mock(() => new Promise(() => {})) + } + transports.push(transport) + return transport + } + } const deps = { createServer: mock(() => server), - StreamableHTTPServerTransport: class { - constructor() { - return transport - } - }, + StreamableHTTPServerTransport: FakeTransport, superviseChild: mock((opts) => { hooks.onStdout = opts.onStdout hooks.onExit = opts.onExit @@ -98,7 +116,7 @@ const makeHarness = (overrides = {}) => { }), ...overrides, } - return { server, transport, supervisor, calls, hooks, deps } + return { server, transport, transports, hold, supervisor, calls, hooks, deps } } const baseOpts = (logger, deps, extra = {}) => ({ @@ -305,7 +323,10 @@ test('a present, disallowed Origin POST is hard-rejected 403 server-side and NOT await startMcpFace(baseOpts(logger, deps)) // A cross-origin simple POST is not preflighted; the server-side gate must // reject it (CSRF defense) rather than merely withholding the ACAO header. - const req = makeReq({ headers: { origin: 'http://evil.com' }, body: '{"jsonrpc":"2.0","method":"tools/call","id":1}' }) + const req = makeReq({ + headers: { origin: 'http://evil.com' }, + body: '{"jsonrpc":"2.0","method":"tools/call","id":1}', + }) const res = makeRes() await fireRequest(server, req, res) expect(res.statusCode).toBe(403) @@ -327,7 +348,10 @@ test('an allowed (loopback) Origin POST passes the gate and is dispatched', asyn const logger = makeLogger() const { server, transport, deps } = makeHarness() await startMcpFace(baseOpts(logger, deps)) - const req = makeReq({ headers: { origin: 'http://localhost:5173' }, body: '{"jsonrpc":"2.0","method":"ping","id":1}' }) + const req = makeReq({ + headers: { origin: 'http://localhost:5173' }, + body: '{"jsonrpc":"2.0","method":"ping","id":1}', + }) const res = makeRes() await fireRequest(server, req, res) expect(transport.handleRequest).toHaveBeenCalledTimes(1) @@ -428,35 +452,67 @@ test('a rejecting transport.handleRequest is swallowed + logged, never escapes', expect(logger.warn).toHaveBeenCalled() }) -test('HTTP onmessage relays to child stdin as one NDJSON line', async () => { +test('HTTP onmessage relays a client request to child stdin as one NDJSON line (id remapped)', async () => { const logger = makeLogger() - const { transport, calls, deps } = makeHarness() + const { server, transport, calls, deps } = makeHarness() await startMcpFace(baseOpts(logger, deps)) + // A request goes through a per-request transport: fire one so the multiplexer + // wires its onmessage, then drive that handler. + await fireRequest(server, makeReq({ body: '{"jsonrpc":"2.0","method":"ping","id":7}' }), makeRes()) transport.onmessage({ jsonrpc: '2.0', method: 'ping', id: 7 }) expect(calls.stdin).toHaveLength(1) - expect(calls.stdin[0]).toBe('{"jsonrpc":"2.0","method":"ping","id":7}\n') + // The client id (7) is remapped to a process-global id so concurrent clients + // can't collide; the method is forwarded verbatim. + const frame = JSON.parse(calls.stdin[0]) + expect(frame.method).toBe('ping') + expect(typeof frame.id).toBe('string') + expect(frame.id).toMatch(/^b:/) + expect(calls.stdin[0].endsWith('\n')).toBe(true) }) -test('child stdout NDJSON lines are parsed and sent to the transport', async () => { +test('a child response routes back to the owning transport with the original client id', async () => { const logger = makeLogger() - const { transport, hooks, deps } = makeHarness() + const { server, transport, calls, hooks, deps } = makeHarness() await startMcpFace(baseOpts(logger, deps)) - hooks.onStdout(Buffer.from('{"jsonrpc":"2.0","result":{},"id":7}\n')) + // Forward a client request (id 7); the mux remaps it to a global id and + // remembers the owning transport. + await fireRequest(server, makeReq({ body: '{"jsonrpc":"2.0","method":"tools/list","id":7}' }), makeRes()) + transport.onmessage({ jsonrpc: '2.0', method: 'tools/list', id: 7 }) + const globalId = JSON.parse(calls.stdin[0]).id + // The child answers under the global id; the mux routes it home as id 7. + hooks.onStdout(Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', result: { tools: [] }, id: globalId })}\n`)) expect(transport.send).toHaveBeenCalledTimes(1) - expect(transport.send.mock.calls[0][0]).toEqual({ jsonrpc: '2.0', result: {}, id: 7 }) + expect(transport.send.mock.calls[0][0]).toEqual({ jsonrpc: '2.0', result: { tools: [] }, id: 7 }) +}) + +test('an id-less child notification is broadcast to a live transport', async () => { + const logger = makeLogger() + const { server, transport, hold, hooks, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps)) + // Hold a GET (SSE) request open so its transport stays LIVE (registered) and can + // receive a server->client notification. + hold.next = true + await fireRequest(server, makeReq({ method: 'GET', url: '/mcp' }), makeRes()) + hooks.onStdout(Buffer.from('{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}\n')) + expect(transport.send).toHaveBeenCalledTimes(1) + expect(transport.send.mock.calls[0][0]).toEqual({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' }) }) test('a rejecting transport.send (disconnected client) is swallowed + logged, never escapes', async () => { const logger = makeLogger() - const { transport, hooks, deps } = makeHarness() - // A stale/disconnected client makes the SDK reject; this must not surface as an - // unhandledRejection (which the CLI's onFatal backstop would treat as fatal). - transport.send = mock(() => Promise.reject(Object.assign(new Error('closed'), { code: 'ERR_CLOSED' }))) + const { server, transport, calls, hooks, deps } = makeHarness() const rejections = [] const onRejection = (err) => rejections.push(err) process.on('unhandledRejection', onRejection) await startMcpFace(baseOpts(logger, deps)) - hooks.onStdout(Buffer.from('{"jsonrpc":"2.0","result":{},"id":7}\n')) + // Forward a client request so the mux owns a pending route to this transport. + await fireRequest(server, makeReq({ body: '{"jsonrpc":"2.0","method":"tools/list","id":7}' }), makeRes()) + transport.onmessage({ jsonrpc: '2.0', method: 'tools/list', id: 7 }) + const globalId = JSON.parse(calls.stdin[0]).id + // A stale/disconnected client makes the SDK reject on send; this must not surface + // as an unhandledRejection (which the CLI's onFatal backstop would treat fatal). + transport.send = mock(() => Promise.reject(Object.assign(new Error('closed'), { code: 'ERR_CLOSED' }))) + hooks.onStdout(Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', result: {}, id: globalId })}\n`)) await new Promise((r) => setTimeout(r, 0)) process.removeListener('unhandledRejection', onRejection) expect(rejections).toHaveLength(0) @@ -473,10 +529,14 @@ test('a malformed child stdout line is dropped + logged by method/id only, never expect(logger.warn).toHaveBeenCalled() }) -test('child exit closes the http server + transport and resolves close()', async () => { +test('child exit closes the http server + every live transport and resolves close()', async () => { const logger = makeLogger() - const { server, transport, hooks, deps } = makeHarness() + const { server, transport, hold, hooks, deps } = makeHarness() await startMcpFace(baseOpts(logger, deps)) + // Hold a request open (handleRequest never settles) so its transport stays live + // through teardown — closeAll must then close it. + hold.next = true + await fireRequest(server, makeReq({ method: 'GET', url: '/mcp' }), makeRes()) hooks.onExit({ code: 0, signal: null }) expect(transport.close).toHaveBeenCalled() expect(server.close).toHaveBeenCalled() @@ -500,10 +560,12 @@ test('child exit also force-closes lingering connections (closeAllConnections)', expect(server.closeAllConnections).toHaveBeenCalled() }) -test('close() closes transport, stops the child, and closes the server: no orphan', async () => { +test('close() closes every live transport, stops the child, and closes the server: no orphan', async () => { const logger = makeLogger() - const { server, transport, calls, deps } = makeHarness() + const { server, transport, hold, calls, deps } = makeHarness() const face = await startMcpFace(baseOpts(logger, deps)) + hold.next = true + await fireRequest(server, makeReq({ method: 'GET', url: '/mcp' }), makeRes()) await face.close() expect(transport.close).toHaveBeenCalled() expect(calls.stopped).toBe(1) From 443d906dde12abd14ea68473076bea87448ce15a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 25 Jun 2026 17:38:09 -0300 Subject: [PATCH 11/45] docs(zeus): plan rename of thunderbolt-stdio-bridge to zeus --- .../specs/2026-06-25-zeus-rename-design.md | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-25-zeus-rename-design.md diff --git a/docs/superpowers/specs/2026-06-25-zeus-rename-design.md b/docs/superpowers/specs/2026-06-25-zeus-rename-design.md new file mode 100644 index 000000000..6c2116c10 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-zeus-rename-design.md @@ -0,0 +1,55 @@ +# Rename `thunderbolt-stdio-bridge` → `zeus` (with `bridge` subcommand) + +**Status:** approved 2026-06-25. Keep the current JS implementation verbatim; this is a **rename + a thin subcommand layer**, not a rewrite. No bridge logic (relay / ACP face / MCP face / multiplexer / tunnel / child supervisor) changes. + +## Why +The bridge becomes the first subcommand of the future **Zeus CLI** (Linear "Zeus CLI" project — a general-purpose agent CLI that will "roll the mcp/acp bridge into it so users install Zeus all-in-one"). Renaming now to `zeus` + `zeus bridge …` pre-positions for that: today `zeus` ships only the `bridge` subcommand; later it grows (`zeus chat`, …). We do NOT implement anything else from the Zeus-CLI project here. + +## Decisions (researched) +- **Handle:** `zeus`. **Invocation:** `zeus bridge --mode [opts] -- ...`. +- **Keep `--`.** Confirmed still idiomatic: every wrapper CLI that execs a child (cargo/npm/npx/kubectl/docker/uv) uses `tool … -- `. Our hand-rolled parser already splits at the first bare `--` correctly. No change. +- **Delivery unchanged in shape** (curl|bash + GitHub Release asset + sha256). Homebrew tap is a future distribution add-on, out of scope. + +## Surface map (old → new) +| Area | Old | New | +| --- | --- | --- | +| package dir | `thunderbolt-stdio-bridge/` | `zeus/` | +| package.json `name` / `bin` | `thunderbolt-stdio-bridge` | `zeus` → `{ "zeus": "bin/cli.js" }` | +| built artifact | `dist/bridge.cjs` + `thunderbolt-stdio-bridge.cmd` | `dist/zeus.cjs` + `zeus.cmd` | +| CLI invocation | `thunderbolt-stdio-bridge --mode acp -- …` | `zeus bridge --mode acp -- …` | +| release tag | `stdio-bridge-v*` | `zeus-v*` | +| workflow file | `stdio-bridge-release.yml` | `zeus-release.yml` (paths `zeus/**`, working-dir `zeus`) | +| install URL | `…/main/thunderbolt-stdio-bridge/install.sh` | `…/main/zeus/install.sh` | +| release asset(s) | `bridge.cjs` (+ `.sha256`) | `zeus.cjs` (+ `.sha256`) | + +## Components + +### 1. Bridge package (`zeus/`) +- **`src/args.js`** — add a thin subcommand layer. Rename the current flag parser to `parseBridgeArgs(argv)` (unchanged logic). New `parseArgs(argv)` dispatch: + - `[]` or `-h`/`--help` → `{ help: 'root' }` + - `-V`/`--version` → `{ version: true }` + - `bridge` → `parseBridgeArgs(argv.slice(1))` (which itself returns `{ help: 'bridge' }` on `bridge --help`, or the resolved bridge opts, tagged `command: 'bridge'`) + - anything else → `UsageError(unknown command: )` (exit 64) +- **`bin/cli.js`** — dispatch on the parse result: root help (lists `bridge`), bridge help (the current flag usage), version, or run the bridge (unchanged). Help text uses `zeus bridge …`. +- **`scripts/build-cli.mjs`** — `outfile = dist/zeus.cjs`; `.cmd` → `dist/zeus.cmd` forwarding to `zeus.cjs`. +- **`install.sh`** — `CMD="zeus"`; asset `zeus.cjs` (+ `zeus.cjs.sha256`); raw URL `…/main/zeus/install.sh`; version resolved from `main`'s `zeus/package.json`. +- **`package.json`** — `name: "zeus"`, `bin: { "zeus": "bin/cli.js" }`, `files` unchanged, scripts unchanged. +- **`README.md`** — rewrite for `zeus` + `zeus bridge …`. + +### 2. Delivery (`.github/workflows/`) +- Rename to `zeus-release.yml`; `paths: ['zeus/**']`; `working-directory: zeus`; tag `zeus-v*`; build `zeus.cjs`; smoke `zeus bridge --help` + ACP/MCP listening; attach `zeus.cjs` + `zeus.cjs.sha256`. + +### 3. App glue (`src/`) +- **`src/lib/agent-bridge-command.ts`** — `bridgeBin = 'zeus'`; `composeBridgeCommand` → `zeus bridge --mode acp [--allow-origin ''] -- `; `composeInstallCommand` → the `…/zeus/install.sh` curl. (`needsAllowOrigin` unchanged.) +- **In-app tutorial** = `src/components/settings/agents/bridge-connect-dialog.tsx` (the "Connect via bridge" 3-step dialog). The commands render from the composers (auto-update); replace any literal `thunderbolt-stdio-bridge` in step copy + the `add-custom-agent-dialog.tsx` hint. + +### 4. Tests +Update assertions to the new name/command: `zeus/{bin/cli.test.js, src/args.test.js}` (root vs bridge help, subcommand parsing, unknown-command), `src/lib/agent-bridge-command.test.ts` (×6), `bridge-connect-dialog.test.tsx`, `copyable-command.test.tsx`, `add-custom-agent-dialog.test.tsx`, `e2e/acp-agents-catalog.spec.ts`. + +## Risks +- **Three names must stay in sync:** bin `zeus` ↔ install.sh (CMD + raw URL + asset) ↔ workflow (paths + tag + asset) ↔ `composeInstallCommand` URL. A mismatch silently breaks install or the connect command. The test suite + a live smoke catch most. +- **Dir rename** ripples into every relative path (build, workflow, install URL). Use `git mv`. +- Pre-merge (#1021 unmerged) ⇒ clean cutover, no `thunderbolt-stdio-bridge` alias / deprecation needed. + +## Validation +`bun run check` + `bun test` (package + app) green; build `dist/zeus.cjs` (single shebang) + smoke `zeus bridge --help/--version/ACP listening/MCP listening`; re-run the live connect flow in the running desktop/web app (ACP + MCP via the renamed bridge). Then `/simplify` + blind GLM (glm cli) + Codex (codex cli) review; act on consensus. From d961a22eafc4582ffbf6d74dea4b568a8b0c2275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 25 Jun 2026 17:48:22 -0300 Subject: [PATCH 12/45] refactor(THU-601): rename bridge CLI package to zeus with a bridge subcommand --- thunderbolt-stdio-bridge/src/args.test.js | 108 ------------- {thunderbolt-stdio-bridge => zeus}/.gitignore | 0 {thunderbolt-stdio-bridge => zeus}/README.md | 28 ++-- {thunderbolt-stdio-bridge => zeus}/bin/cli.js | 19 ++- .../bin/cli.test.js | 64 +++++--- {thunderbolt-stdio-bridge => zeus}/bun.lock | 0 {thunderbolt-stdio-bridge => zeus}/install.sh | 24 +-- .../package.json | 4 +- .../scripts/build-cli.mjs | 12 +- .../src/args.js | 37 ++++- zeus/src/args.test.js | 149 ++++++++++++++++++ .../src/child.js | 0 .../src/child.test.js | 0 .../src/errors.js | 0 .../src/errors.test.js | 0 {thunderbolt-stdio-bridge => zeus}/src/log.js | 0 .../src/log.test.js | 0 .../src/mcp-multiplexer.js | 0 .../src/mcp-multiplexer.test.js | 0 .../src/mcp-server.integration.test.js | 0 .../src/mcp-server.js | 0 .../src/mcp-server.test.js | 0 .../src/relay.js | 0 .../src/relay.test.js | 0 .../src/server.js | 0 .../src/server.test.js | 0 .../src/tunnel.js | 0 .../src/tunnel.test.js | 0 .../src/util.js | 0 .../src/util.test.js | 0 30 files changed, 274 insertions(+), 171 deletions(-) delete mode 100644 thunderbolt-stdio-bridge/src/args.test.js rename {thunderbolt-stdio-bridge => zeus}/.gitignore (100%) rename {thunderbolt-stdio-bridge => zeus}/README.md (86%) rename {thunderbolt-stdio-bridge => zeus}/bin/cli.js (93%) rename {thunderbolt-stdio-bridge => zeus}/bin/cli.test.js (70%) rename {thunderbolt-stdio-bridge => zeus}/bun.lock (100%) rename {thunderbolt-stdio-bridge => zeus}/install.sh (80%) rename {thunderbolt-stdio-bridge => zeus}/package.json (89%) rename {thunderbolt-stdio-bridge => zeus}/scripts/build-cli.mjs (79%) rename {thunderbolt-stdio-bridge => zeus}/src/args.js (68%) create mode 100644 zeus/src/args.test.js rename {thunderbolt-stdio-bridge => zeus}/src/child.js (100%) rename {thunderbolt-stdio-bridge => zeus}/src/child.test.js (100%) rename {thunderbolt-stdio-bridge => zeus}/src/errors.js (100%) rename {thunderbolt-stdio-bridge => zeus}/src/errors.test.js (100%) rename {thunderbolt-stdio-bridge => zeus}/src/log.js (100%) rename {thunderbolt-stdio-bridge => zeus}/src/log.test.js (100%) rename {thunderbolt-stdio-bridge => zeus}/src/mcp-multiplexer.js (100%) rename {thunderbolt-stdio-bridge => zeus}/src/mcp-multiplexer.test.js (100%) rename {thunderbolt-stdio-bridge => zeus}/src/mcp-server.integration.test.js (100%) rename {thunderbolt-stdio-bridge => zeus}/src/mcp-server.js (100%) rename {thunderbolt-stdio-bridge => zeus}/src/mcp-server.test.js (100%) rename {thunderbolt-stdio-bridge => zeus}/src/relay.js (100%) rename {thunderbolt-stdio-bridge => zeus}/src/relay.test.js (100%) rename {thunderbolt-stdio-bridge => zeus}/src/server.js (100%) rename {thunderbolt-stdio-bridge => zeus}/src/server.test.js (100%) rename {thunderbolt-stdio-bridge => zeus}/src/tunnel.js (100%) rename {thunderbolt-stdio-bridge => zeus}/src/tunnel.test.js (100%) rename {thunderbolt-stdio-bridge => zeus}/src/util.js (100%) rename {thunderbolt-stdio-bridge => zeus}/src/util.test.js (100%) diff --git a/thunderbolt-stdio-bridge/src/args.test.js b/thunderbolt-stdio-bridge/src/args.test.js deleted file mode 100644 index f0b076278..000000000 --- a/thunderbolt-stdio-bridge/src/args.test.js +++ /dev/null @@ -1,108 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -'use strict' - -const { test, expect } = require('bun:test') -const { parseArgs } = require('./args') -const { UsageError } = require('./errors') - -test('--mode acp -- node agent.js → mode acp, launch=[node, agent.js]', () => { - const parsed = parseArgs(['--mode', 'acp', '--', 'node', 'agent.js']) - expect(parsed.mode).toBe('acp') - expect(parsed.launch).toEqual(['node', 'agent.js']) -}) - -test('--mode mcp --tunnel -- srv → tunnel true', () => { - expect(parseArgs(['--mode', 'mcp', '--tunnel', '--', 'srv']).tunnel).toBe(true) -}) - -test('--tunnel --mode acp -- x → UsageError (tunnel requires mcp)', () => { - expect(() => parseArgs(['--tunnel', '--mode', 'acp', '--', 'x'])).toThrow(UsageError) -}) - -test('missing --mode → UsageError', () => { - expect(() => parseArgs(['--', 'node', 'x.js'])).toThrow(UsageError) -}) - -test('--mode bogus → UsageError', () => { - expect(() => parseArgs(['--mode', 'bogus', '--', 'x'])).toThrow(UsageError) -}) - -test('no `--` delimiter → UsageError (empty launch)', () => { - expect(() => parseArgs(['--mode', 'acp'])).toThrow(UsageError) -}) - -test('`--` with nothing after → UsageError', () => { - expect(() => parseArgs(['--mode', 'acp', '--'])).toThrow(UsageError) -}) - -test('repeated --allow-origin a --allow-origin b → allowOrigins=[a,b]', () => { - const parsed = parseArgs(['--mode', 'acp', '--allow-origin', 'a', '--allow-origin', 'b', '--', 'x']) - expect(parsed.allowOrigins).toEqual(['a', 'b']) -}) - -test('--allow-any-origin sets the flag true', () => { - expect(parseArgs(['--mode', 'acp', '--allow-any-origin', '--', 'x']).allowAnyOrigin).toBe(true) -}) - -test('--port 8080 parses to 8080; --port 70000 / --port abc → UsageError', () => { - expect(parseArgs(['--mode', 'acp', '--port', '8080', '--', 'x']).port).toBe(8080) - expect(() => parseArgs(['--mode', 'acp', '--port', '70000', '--', 'x'])).toThrow(UsageError) - expect(() => parseArgs(['--mode', 'acp', '--port', 'abc', '--', 'x'])).toThrow(UsageError) -}) - -test('--host 0.0.0.0 retained verbatim', () => { - expect(parseArgs(['--mode', 'acp', '--host', '0.0.0.0', '--', 'x']).host).toBe('0.0.0.0') -}) - -test('--help returns {help:true} ignoring other flags; --version returns {version:true}', () => { - expect(parseArgs(['--mode', 'bogus', '--help'])).toEqual({ help: true }) - expect(parseArgs(['--version'])).toEqual({ version: true }) -}) - -test('-h and -V short aliases work', () => { - expect(parseArgs(['-h'])).toEqual({ help: true }) - expect(parseArgs(['-V'])).toEqual({ version: true }) -}) - -test('everything after the first `--` is preserved verbatim including further `--` and dashes', () => { - const parsed = parseArgs(['--mode', 'mcp', '--', 'npx', 'srv', '--', '--flag', '-x']) - expect(parsed.launch).toEqual(['npx', 'srv', '--', '--flag', '-x']) -}) - -test('unknown --frob → UsageError', () => { - expect(() => parseArgs(['--mode', 'acp', '--frob', '--', 'x'])).toThrow(UsageError) -}) - -test('--json and --verbose toggle their booleans; defaults are false', () => { - const on = parseArgs(['--mode', 'acp', '--json', '--verbose', '--', 'x']) - expect(on.json).toBe(true) - expect(on.verbose).toBe(true) - const off = parseArgs(['--mode', 'acp', '--', 'x']) - expect(off.json).toBe(false) - expect(off.verbose).toBe(false) -}) - -test('default host=127.0.0.1 and port=0 when omitted', () => { - const parsed = parseArgs(['--mode', 'acp', '--', 'x']) - expect(parsed.host).toBe('127.0.0.1') - expect(parsed.port).toBe(0) -}) - -test('flag expecting a value at end-of-argv → UsageError', () => { - expect(() => parseArgs(['--mode'])).toThrow(UsageError) -}) - -test('a flag value that itself looks like a flag is treated as a missing value → UsageError', () => { - expect(() => parseArgs(['--host', '--port', '--', 'x'])).toThrow(UsageError) -}) - -test('flags may appear in any order before `--`', () => { - const parsed = parseArgs(['--verbose', '--port', '3000', '--mode', 'mcp', '--json', '--', 'srv']) - expect(parsed.mode).toBe('mcp') - expect(parsed.port).toBe(3000) - expect(parsed.verbose).toBe(true) - expect(parsed.json).toBe(true) -}) diff --git a/thunderbolt-stdio-bridge/.gitignore b/zeus/.gitignore similarity index 100% rename from thunderbolt-stdio-bridge/.gitignore rename to zeus/.gitignore diff --git a/thunderbolt-stdio-bridge/README.md b/zeus/README.md similarity index 86% rename from thunderbolt-stdio-bridge/README.md rename to zeus/README.md index 1993cd0bc..a0e076002 100644 --- a/thunderbolt-stdio-bridge/README.md +++ b/zeus/README.md @@ -2,9 +2,10 @@ - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -# thunderbolt-stdio-bridge +# zeus -Bridges a local **stdio** ACP or MCP server to a **loopback** network face so a +`zeus` is Thunderbolt's local stdio bridge toolkit. Its `bridge` subcommand +bridges a local **stdio** ACP or MCP server to a **loopback** network face so a browser app can talk to it: - `--mode acp` — exposes the child over a **WebSocket** (`ws://127.0.0.1:PORT`). @@ -20,7 +21,8 @@ that treats the bridge as a stdio child can never have its framing corrupted. ## Usage ``` -thunderbolt-stdio-bridge --mode [options] -- [args...] +zeus [options] +zeus bridge --mode [options] -- [args...] ``` Everything after the first bare `--` is the child launch argv, passed verbatim @@ -28,18 +30,18 @@ to `spawn`. ```sh # ACP: bridge a local stdio ACP agent to a loopback WebSocket -thunderbolt-stdio-bridge --mode acp -- node my-acp-agent.js +zeus bridge --mode acp -- node my-acp-agent.js # stderr: ws://127.0.0.1:54123 # MCP: bridge a local stdio MCP server to a loopback HTTP face -thunderbolt-stdio-bridge --mode mcp -- npx @modelcontextprotocol/server-everything +zeus bridge --mode mcp -- npx @modelcontextprotocol/server-everything # stderr: http://127.0.0.1:54124/mcp # MCP behind a public cloudflared quick tunnel (mints a mandatory bearer) -thunderbolt-stdio-bridge --mode mcp --tunnel -- npx some-mcp-server +zeus bridge --mode mcp --tunnel -- npx some-mcp-server ``` -### Options +### `zeus bridge` options | Flag | Default | Meaning | | -------------------- | ----------- | ----------------------------------------------------------------- | @@ -82,22 +84,22 @@ orphans the process it spawned, and it never restarts it. ## Installation -The bridge ships as a single self-contained `bridge.cjs` attached to a GitHub +`zeus` ships as a single self-contained `zeus.cjs` attached to a GitHub release — there is **no npm publish**. `install.sh` downloads that artifact and -links it onto your `PATH` as `thunderbolt-stdio-bridge`: +links it onto your `PATH` as `zeus`: ```sh -curl -fsSL https://raw.githubusercontent.com/thunderbird/thunderbolt/main/thunderbolt-stdio-bridge/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/thunderbird/thunderbolt/main/zeus/install.sh | bash ``` -The app invokes the published binary via `npx thunderbolt-stdio-bridge`. +The app invokes the published binary as `zeus bridge ...`. ## Building `bun run build` runs `scripts/build-cli.mjs`, which bundles the CLI with esbuild -into `dist/bridge.cjs` (Node 18 target, `bufferutil`/`utf-8-validate` left +into `dist/zeus.cjs` (Node 18 target, `bufferutil`/`utf-8-validate` left external, the version inlined from `package.json`, shebang prepended) and emits a -companion Windows `.cmd` launcher. +companion Windows `dist/zeus.cmd` launcher. ## Development diff --git a/thunderbolt-stdio-bridge/bin/cli.js b/zeus/bin/cli.js similarity index 93% rename from thunderbolt-stdio-bridge/bin/cli.js rename to zeus/bin/cli.js index c2259f45b..ffb39e7f1 100644 --- a/thunderbolt-stdio-bridge/bin/cli.js +++ b/zeus/bin/cli.js @@ -22,10 +22,23 @@ const { startTunnel, generateBearer } = require('../src/tunnel') // esbuild inlines this; a fallback keeps the un-bundled bin runnable from source. const BRIDGE_VERSION = typeof __BRIDGE_VERSION__ !== 'undefined' ? __BRIDGE_VERSION__ : '0.0.0-dev' -const HELP_TEXT = `thunderbolt-stdio-bridge — bridge a local stdio ACP/MCP server to a loopback face. +const ROOT_HELP_TEXT = `zeus — Thunderbolt's local stdio bridge toolkit. Usage: - thunderbolt-stdio-bridge --mode [options] -- ... + zeus [options] + +Commands: + bridge bridge a local stdio ACP/MCP server to a loopback face + +Run \`zeus bridge --help\` for the bridge options. + + -h, --help print this help and exit + -V, --version print the version and exit` + +const BRIDGE_HELP_TEXT = `zeus bridge — bridge a local stdio ACP/MCP server to a loopback face. + +Usage: + zeus bridge --mode [options] -- ... Everything after \`--\` is the child launch argv, passed verbatim to spawn. @@ -82,7 +95,7 @@ const run = async ({ return exit(toExitCode(parsed.error)) } if (parsed.help) { - stdout.write(`${HELP_TEXT}\n`) + stdout.write(`${parsed.help === 'root' ? ROOT_HELP_TEXT : BRIDGE_HELP_TEXT}\n`) return exit(EX.OK) } if (parsed.version) { diff --git a/thunderbolt-stdio-bridge/bin/cli.test.js b/zeus/bin/cli.test.js similarity index 70% rename from thunderbolt-stdio-bridge/bin/cli.test.js rename to zeus/bin/cli.test.js index e06ad6b31..ba16b2eb0 100644 --- a/thunderbolt-stdio-bridge/bin/cli.test.js +++ b/zeus/bin/cli.test.js @@ -58,10 +58,28 @@ const makeHarness = (over = {}) => { } } -test('--help prints usage to stdout and exits 0 (no child spawned)', async () => { +test('root --help prints root usage (lists the bridge command) to stdout and exits 0 (no child spawned)', async () => { const h = makeHarness() await run({ argv: ['--help'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) expect(h.stdout.text()).toContain('Usage:') + expect(h.stdout.text()).toContain('zeus ') + expect(h.stdout.text()).toContain('bridge') + expect(h.exit).toHaveBeenCalledWith(0) + expect(h.startBridge).not.toHaveBeenCalled() +}) + +test('no args prints root usage and exits 0', async () => { + const h = makeHarness() + await run({ argv: [], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + expect(h.stdout.text()).toContain('zeus ') + expect(h.exit).toHaveBeenCalledWith(0) + expect(h.startBridge).not.toHaveBeenCalled() +}) + +test('bridge --help prints the bridge usage to stdout and exits 0 (no child spawned)', async () => { + const h = makeHarness() + await run({ argv: ['bridge', '--help'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + expect(h.stdout.text()).toContain('zeus bridge --mode ') expect(h.exit).toHaveBeenCalledWith(0) expect(h.startBridge).not.toHaveBeenCalled() }) @@ -73,18 +91,26 @@ test('--version prints the version and exits 0', async () => { expect(h.exit).toHaveBeenCalledWith(0) }) -test('missing --mode -> stderr usage message, exit 64, no spawn', async () => { +test('an unknown command -> stderr usage message, exit 64, no spawn', async () => { + const h = makeHarness() + await run({ argv: ['bogus'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + expect(h.exit).toHaveBeenCalledWith(64) + expect(h.stderr.text()).toContain('unknown command') + expect(h.startBridge).not.toHaveBeenCalled() +}) + +test('bridge with missing --mode -> stderr usage message, exit 64, no spawn', async () => { const h = makeHarness() - await run({ argv: ['--', 'node', 'a.js'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + await run({ argv: ['bridge', '--', 'node', 'a.js'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) expect(h.exit).toHaveBeenCalledWith(64) expect(h.stderr.text().length).toBeGreaterThan(0) expect(h.startBridge).not.toHaveBeenCalled() }) -test('--mode acp -- dispatches to startBridge with parsed launch + options', async () => { +test('bridge --mode acp -- dispatches to startBridge with parsed launch + options', async () => { const h = makeHarness() await run({ - argv: ['--mode', 'acp', '--allow-origin', 'http://a', '--', 'node', 'agent.js'], + argv: ['bridge', '--mode', 'acp', '--allow-origin', 'http://a', '--', 'node', 'agent.js'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, @@ -111,7 +137,7 @@ test('--mode mcp --tunnel: face binds BEFORE the tunnel, which targets the face h.deps.startMcpFace = startMcpFace h.deps.startTunnel = startTunnel await run({ - argv: ['--mode', 'mcp', '--tunnel', '--', 'srv'], + argv: ['bridge', '--mode', 'mcp', '--tunnel', '--', 'srv'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, @@ -131,14 +157,14 @@ test('--mode mcp --tunnel: face binds BEFORE the tunnel, which targets the face test('--mode mcp without --tunnel does not start a tunnel and bearer is undefined', async () => { const h = makeHarness() - await run({ argv: ['--mode', 'mcp', '--', 'srv'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + await run({ argv: ['bridge', '--mode', 'mcp', '--', 'srv'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) expect(h.startTunnel).not.toHaveBeenCalled() expect(h.startMcpFace.mock.calls[0][0].bearer).toBeUndefined() }) test('SIGINT handler SIGKILLs (via face close) the live child then exits 130', async () => { const h = makeHarness() - await run({ argv: ['--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + await run({ argv: ['bridge', '--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) await h.signals.SIGINT() expect(h.face.close).toHaveBeenCalledTimes(1) expect(h.exit).toHaveBeenLastCalledWith(130) @@ -146,7 +172,7 @@ test('SIGINT handler SIGKILLs (via face close) the live child then exits 130', a test('SIGTERM handler closes the face then exits 0', async () => { const h = makeHarness() - await run({ argv: ['--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + await run({ argv: ['bridge', '--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) await h.signals.SIGTERM() expect(h.face.close).toHaveBeenCalledTimes(1) expect(h.exit).toHaveBeenLastCalledWith(0) @@ -158,7 +184,7 @@ test('a bind failure from the face (UnavailableError EADDRINUSE) maps to exit 69 throw err }) const h = makeHarness({ deps: { startBridge } }) - await run({ argv: ['--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + await run({ argv: ['bridge', '--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) expect(h.exit).toHaveBeenLastCalledWith(69) }) @@ -167,14 +193,14 @@ test('an unexpected internal throw maps to exit 70', async () => { throw new Error('boom') }) const h = makeHarness({ deps: { startBridge } }) - await run({ argv: ['--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + await run({ argv: ['bridge', '--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) expect(h.exit).toHaveBeenLastCalledWith(70) }) test('logger is constructed with json/verbose flags and the injected stderr sink', async () => { const h = makeHarness() await run({ - argv: ['--mode', 'acp', '--json', '--verbose', '--', 'x'], + argv: ['bridge', '--mode', 'acp', '--json', '--verbose', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, @@ -196,7 +222,7 @@ test('insecureFlagWarnings are emitted to stderr (via logger.warn) before the fa }) h.deps.startBridge = startBridge await run({ - argv: ['--mode', 'acp', '--allow-any-origin', '--host', '0.0.0.0', '--', 'x'], + argv: ['bridge', '--mode', 'acp', '--allow-any-origin', '--host', '0.0.0.0', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, @@ -212,7 +238,7 @@ test('on clean child exit the bridge exits with the child-derived code (0)', asy return h.face }) const h = makeHarness({ deps: { startBridge } }) - await run({ argv: ['--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + await run({ argv: ['bridge', '--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) await captured.onChildExit({ code: 0, signal: null }) expect(h.exit).toHaveBeenLastCalledWith(0) }) @@ -224,14 +250,14 @@ test('a nonzero child exit derives exit 70', async () => { return h.face }) const h = makeHarness({ deps: { startBridge } }) - await run({ argv: ['--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + await run({ argv: ['bridge', '--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) await captured.onChildExit({ code: 1, signal: null }) expect(h.exit).toHaveBeenLastCalledWith(70) }) test('uncaughtException SIGKILLs the live child (face.kill) and exits 70 (never-orphan)', async () => { const h = makeHarness() - await run({ argv: ['--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + await run({ argv: ['bridge', '--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) h.signals.uncaughtException(Object.assign(new Error('boom'), { code: 'ERR_FOO' })) expect(h.face.kill).toHaveBeenCalledTimes(1) expect(h.exit).toHaveBeenLastCalledWith(70) @@ -239,15 +265,15 @@ test('uncaughtException SIGKILLs the live child (face.kill) and exits 70 (never- test('unhandledRejection SIGKILLs the live child (face.kill) and exits 70 (never-orphan)', async () => { const h = makeHarness() - await run({ argv: ['--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + await run({ argv: ['bridge', '--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) h.signals.unhandledRejection(new Error('rejected')) expect(h.face.kill).toHaveBeenCalledTimes(1) expect(h.exit).toHaveBeenLastCalledWith(70) }) -test('--tunnel without --mode mcp is a usage error (exit 64)', async () => { +test('bridge --tunnel without --mode mcp is a usage error (exit 64)', async () => { const h = makeHarness() - await run({ argv: ['--tunnel', '--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) + await run({ argv: ['bridge', '--tunnel', '--mode', 'acp', '--', 'x'], stdout: h.stdout, stderr: h.stderr, exit: h.exit, deps: h.deps }) expect(h.exit).toHaveBeenCalledWith(64) expect(h.startBridge).not.toHaveBeenCalled() }) diff --git a/thunderbolt-stdio-bridge/bun.lock b/zeus/bun.lock similarity index 100% rename from thunderbolt-stdio-bridge/bun.lock rename to zeus/bun.lock diff --git a/thunderbolt-stdio-bridge/install.sh b/zeus/install.sh similarity index 80% rename from thunderbolt-stdio-bridge/install.sh rename to zeus/install.sh index 040e1e39d..af74ee143 100755 --- a/thunderbolt-stdio-bridge/install.sh +++ b/zeus/install.sh @@ -3,22 +3,22 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. -# thunderbolt-stdio-bridge installer. +# zeus installer. # -# Downloads the prebuilt bridge.cjs bundle from GitHub Releases and installs it +# Downloads the prebuilt zeus.cjs bundle from GitHub Releases and installs it # as a bare command on the npm global bin dir (next to npm/npx). Requires node; -# no Bun, no registry publish, no runtime bundling. bridge.cjs already ships a +# no Bun, no registry publish, no runtime bundling. zeus.cjs already ships a # `#!/usr/bin/env node` shebang, so dropping it in under the command name makes -# `thunderbolt-stdio-bridge ` behave like any global node CLI. +# `zeus ` behave like any global node CLI. # -# curl -fsSL https://raw.githubusercontent.com/thunderbird/thunderbolt/main/thunderbolt-stdio-bridge/install.sh | bash +# curl -fsSL https://raw.githubusercontent.com/thunderbird/thunderbolt/main/zeus/install.sh | bash # # Pin a version: ... | bash -s -- 0.1.0 # Custom bin dir: THUNDERBOLT_BIN_DIR=/opt/bin ... | bash set -euo pipefail REPO="thunderbird/thunderbolt" -CMD="thunderbolt-stdio-bridge" +CMD="zeus" command -v node >/dev/null 2>&1 || { echo "error: node is required (https://nodejs.org)" >&2; exit 1; } command -v npm >/dev/null 2>&1 || { echo "error: npm is required (ships with node)" >&2; exit 1; } @@ -41,13 +41,13 @@ fi # Resolve the version: explicit arg/env wins; otherwise read main's package.json # (always current, no GitHub API call so no rate limit). -VERSION="${1:-${THUNDERBOLT_STDIO_BRIDGE_VERSION:-}}" +VERSION="${1:-${ZEUS_VERSION:-}}" if [ -z "$VERSION" ]; then - VERSION=$(curl -fsSL "https://raw.githubusercontent.com/$REPO/main/thunderbolt-stdio-bridge/package.json" \ + VERSION=$(curl -fsSL "https://raw.githubusercontent.com/$REPO/main/zeus/package.json" \ | sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' | head -n1) [ -n "$VERSION" ] || { echo "error: could not resolve latest version" >&2; exit 1; } fi -URL="https://github.com/$REPO/releases/download/stdio-bridge-v$VERSION/bridge.cjs" +URL="https://github.com/$REPO/releases/download/zeus-v$VERSION/zeus.cjs" SUM_URL="$URL.sha256" echo "Installing $CMD $VERSION -> $BIN_DIR/$CMD" @@ -60,17 +60,17 @@ curl -fL --progress-bar -o "$TMP" "$URL" # Verify the download against the Release's published SHA-256 before installing. # Pick whichever checksum tool is present; the published file is in `shasum -c` -# format (` bridge.cjs`), so verify from $TMP's own directory under that +# format (` zeus.cjs`), so verify from $TMP's own directory under that # basename. Abort on mismatch or a missing tool — never install unverified bytes. curl -fsSL -o "$SUM_TMP" "$SUM_URL" EXPECTED=$(awk '{print $1; exit}' "$SUM_TMP") [ -n "$EXPECTED" ] || { echo "error: could not read published checksum" >&2; exit 1; } if command -v shasum >/dev/null 2>&1; then echo "$EXPECTED $TMP" | shasum -a 256 -c - >/dev/null 2>&1 \ - || { echo "error: checksum verification failed for bridge.cjs" >&2; exit 1; } + || { echo "error: checksum verification failed for zeus.cjs" >&2; exit 1; } elif command -v sha256sum >/dev/null 2>&1; then echo "$EXPECTED $TMP" | sha256sum -c - >/dev/null 2>&1 \ - || { echo "error: checksum verification failed for bridge.cjs" >&2; exit 1; } + || { echo "error: checksum verification failed for zeus.cjs" >&2; exit 1; } else echo "error: need shasum or sha256sum to verify the download" >&2; exit 1 fi diff --git a/thunderbolt-stdio-bridge/package.json b/zeus/package.json similarity index 89% rename from thunderbolt-stdio-bridge/package.json rename to zeus/package.json index a945d42df..e2b7d8e45 100644 --- a/thunderbolt-stdio-bridge/package.json +++ b/zeus/package.json @@ -1,11 +1,11 @@ { - "name": "thunderbolt-stdio-bridge", + "name": "zeus", "version": "0.1.0", "description": "Bridges a local stdio ACP/MCP server to a loopback WebSocket (ACP) or Streamable HTTP (MCP) face so a browser app can reach it.", "license": "MPL-2.0", "type": "commonjs", "bin": { - "thunderbolt-stdio-bridge": "bin/cli.js" + "zeus": "bin/cli.js" }, "files": [ "bin", diff --git a/thunderbolt-stdio-bridge/scripts/build-cli.mjs b/zeus/scripts/build-cli.mjs similarity index 79% rename from thunderbolt-stdio-bridge/scripts/build-cli.mjs rename to zeus/scripts/build-cli.mjs index 2b2e5641a..0a96d1acd 100644 --- a/thunderbolt-stdio-bridge/scripts/build-cli.mjs +++ b/zeus/scripts/build-cli.mjs @@ -3,7 +3,7 @@ // file, You can obtain one at http://mozilla.org/MPL/2.0/. // Bundles bin/cli.js (and its src/ deps) into a single self-contained -// dist/bridge.cjs that install.sh ships verbatim from GitHub Releases. esbuild +// dist/zeus.cjs that install.sh ships verbatim from GitHub Releases. esbuild // inlines the package version as the __BRIDGE_VERSION__ global referenced by // the CLI (so --version works in the bundle), prepends a node shebang so the // bare file is directly executable, and keeps the native ws acceleration addons @@ -15,7 +15,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const root = join(dirname(fileURLToPath(import.meta.url)), '..') -const outfile = join(root, 'dist', 'bridge.cjs') +const outfile = join(root, 'dist', 'zeus.cjs') const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) @@ -37,9 +37,9 @@ await build({ await chmod(outfile, 0o755) -// Windows ignores the shebang and won't run a bare bridge.cjs from PATH, so emit -// a sibling .cmd shim that forwards every arg to node bridge.cjs. -const cmd = '@echo off\r\nnode "%~dp0bridge.cjs" %*\r\n' -await writeFile(join(root, 'dist', 'thunderbolt-stdio-bridge.cmd'), cmd) +// Windows ignores the shebang and won't run a bare zeus.cjs from PATH, so emit +// a sibling .cmd shim that forwards every arg to node zeus.cjs. +const cmd = '@echo off\r\nnode "%~dp0zeus.cjs" %*\r\n' +await writeFile(join(root, 'dist', 'zeus.cmd'), cmd) console.error(`built ${outfile} (v${pkg.version})`) diff --git a/thunderbolt-stdio-bridge/src/args.js b/zeus/src/args.js similarity index 68% rename from thunderbolt-stdio-bridge/src/args.js rename to zeus/src/args.js index 86093f9ef..02f9951d2 100644 --- a/thunderbolt-stdio-bridge/src/args.js +++ b/zeus/src/args.js @@ -19,15 +19,16 @@ const VALID_MODES = new Set(['acp', 'mcp']) const looksLikeFlag = (token) => token !== undefined && token.startsWith('-') /** - * Pure argv parser. Splits flags from the child launch argv at the first bare - * `--`, validates flag values and cross-flag rules, and returns a fully - * resolved options object — or a `{help}`/`{version}` intent. Throws UsageError - * on any invalid input so the CLI maps it to exit 64. + * Pure flag parser for the `bridge` subcommand. Splits flags from the child + * launch argv at the first bare `--`, validates flag values and cross-flag + * rules, and returns a fully resolved options object — or a + * `{help:'bridge'}`/`{version}` intent. Throws UsageError on any invalid input + * so the CLI maps it to exit 64. * @param {string[]} argv - * @returns {ParsedArgs | { help: true } | { version: true }} + * @returns {ParsedArgs | { help: 'bridge' } | { version: true }} */ -const parseArgs = (argv) => { - if (argv.includes('--help') || argv.includes('-h')) return { help: true } +const parseBridgeArgs = (argv) => { + if (argv.includes('--help') || argv.includes('-h')) return { help: 'bridge' } if (argv.includes('--version') || argv.includes('-V')) return { version: true } const delimiterIndex = argv.indexOf('--') @@ -91,4 +92,24 @@ const parseArgs = (argv) => { return /** @type {ParsedArgs} */ ({ ...opts, launch }) } -module.exports = { parseArgs } +/** + * Top-level subcommand dispatcher. Routes the first token to a subcommand + * (currently only `bridge`), or short-circuits to a root help/version intent. + * Resolved bridge opts carry `command: 'bridge'`. Throws UsageError (→ exit 64) + * on an unknown command. + * @param {string[]} argv + * @returns {(ParsedArgs & { command: 'bridge' }) | { help: 'root' } | { help: 'bridge' } | { version: true }} + */ +const parseArgs = (argv) => { + const [token, ...rest] = argv + if (token === undefined || token === '--help' || token === '-h') return { help: 'root' } + if (token === '--version' || token === '-V') return { version: true } + if (token === 'bridge') { + const parsed = parseBridgeArgs(rest) + if ('help' in parsed || 'version' in parsed) return parsed + return { ...parsed, command: 'bridge' } + } + throw new UsageError(`unknown command: ${token}`) +} + +module.exports = { parseArgs, parseBridgeArgs } diff --git a/zeus/src/args.test.js b/zeus/src/args.test.js new file mode 100644 index 000000000..abfb3dae3 --- /dev/null +++ b/zeus/src/args.test.js @@ -0,0 +1,149 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict' + +const { test, expect } = require('bun:test') +const { parseArgs, parseBridgeArgs } = require('./args') +const { UsageError } = require('./errors') + +// --- parseArgs dispatcher --- + +test('no args → root help intent', () => { + expect(parseArgs([])).toEqual({ help: 'root' }) +}) + +test('-h / --help at the root → root help intent', () => { + expect(parseArgs(['-h'])).toEqual({ help: 'root' }) + expect(parseArgs(['--help'])).toEqual({ help: 'root' }) +}) + +test('-V / --version at the root → version intent', () => { + expect(parseArgs(['-V'])).toEqual({ version: true }) + expect(parseArgs(['--version'])).toEqual({ version: true }) +}) + +test('bridge subcommand resolves bridge opts tagged command=bridge', () => { + const parsed = parseArgs(['bridge', '--mode', 'acp', '--', 'node', 'agent.js']) + expect(parsed.command).toBe('bridge') + expect(parsed.mode).toBe('acp') + expect(parsed.launch).toEqual(['node', 'agent.js']) +}) + +test('bridge --help → bridge help intent (no command tag)', () => { + expect(parseArgs(['bridge', '--help'])).toEqual({ help: 'bridge' }) +}) + +test('bridge --version → version intent', () => { + expect(parseArgs(['bridge', '--version'])).toEqual({ version: true }) +}) + +test('bridge with an invalid flag combo still throws UsageError through the dispatcher', () => { + expect(() => parseArgs(['bridge', '--tunnel', '--mode', 'acp', '--', 'x'])).toThrow(UsageError) +}) + +test('unknown command → UsageError', () => { + expect(() => parseArgs(['bogus'])).toThrow(UsageError) +}) + +// --- parseBridgeArgs flag parsing --- + +test('--mode acp -- node agent.js → mode acp, launch=[node, agent.js]', () => { + const parsed = parseBridgeArgs(['--mode', 'acp', '--', 'node', 'agent.js']) + expect(parsed.mode).toBe('acp') + expect(parsed.launch).toEqual(['node', 'agent.js']) +}) + +test('--mode mcp --tunnel -- srv → tunnel true', () => { + expect(parseBridgeArgs(['--mode', 'mcp', '--tunnel', '--', 'srv']).tunnel).toBe(true) +}) + +test('--tunnel --mode acp -- x → UsageError (tunnel requires mcp)', () => { + expect(() => parseBridgeArgs(['--tunnel', '--mode', 'acp', '--', 'x'])).toThrow(UsageError) +}) + +test('missing --mode → UsageError', () => { + expect(() => parseBridgeArgs(['--', 'node', 'x.js'])).toThrow(UsageError) +}) + +test('--mode bogus → UsageError', () => { + expect(() => parseBridgeArgs(['--mode', 'bogus', '--', 'x'])).toThrow(UsageError) +}) + +test('no `--` delimiter → UsageError (empty launch)', () => { + expect(() => parseBridgeArgs(['--mode', 'acp'])).toThrow(UsageError) +}) + +test('`--` with nothing after → UsageError', () => { + expect(() => parseBridgeArgs(['--mode', 'acp', '--'])).toThrow(UsageError) +}) + +test('repeated --allow-origin a --allow-origin b → allowOrigins=[a,b]', () => { + const parsed = parseBridgeArgs(['--mode', 'acp', '--allow-origin', 'a', '--allow-origin', 'b', '--', 'x']) + expect(parsed.allowOrigins).toEqual(['a', 'b']) +}) + +test('--allow-any-origin sets the flag true', () => { + expect(parseBridgeArgs(['--mode', 'acp', '--allow-any-origin', '--', 'x']).allowAnyOrigin).toBe(true) +}) + +test('--port 8080 parses to 8080; --port 70000 / --port abc → UsageError', () => { + expect(parseBridgeArgs(['--mode', 'acp', '--port', '8080', '--', 'x']).port).toBe(8080) + expect(() => parseBridgeArgs(['--mode', 'acp', '--port', '70000', '--', 'x'])).toThrow(UsageError) + expect(() => parseBridgeArgs(['--mode', 'acp', '--port', 'abc', '--', 'x'])).toThrow(UsageError) +}) + +test('--host 0.0.0.0 retained verbatim', () => { + expect(parseBridgeArgs(['--mode', 'acp', '--host', '0.0.0.0', '--', 'x']).host).toBe('0.0.0.0') +}) + +test('--help returns {help:bridge} ignoring other flags; --version returns {version:true}', () => { + expect(parseBridgeArgs(['--mode', 'bogus', '--help'])).toEqual({ help: 'bridge' }) + expect(parseBridgeArgs(['--version'])).toEqual({ version: true }) +}) + +test('-h and -V short aliases work', () => { + expect(parseBridgeArgs(['-h'])).toEqual({ help: 'bridge' }) + expect(parseBridgeArgs(['-V'])).toEqual({ version: true }) +}) + +test('everything after the first `--` is preserved verbatim including further `--` and dashes', () => { + const parsed = parseBridgeArgs(['--mode', 'mcp', '--', 'npx', 'srv', '--', '--flag', '-x']) + expect(parsed.launch).toEqual(['npx', 'srv', '--', '--flag', '-x']) +}) + +test('unknown --frob → UsageError', () => { + expect(() => parseBridgeArgs(['--mode', 'acp', '--frob', '--', 'x'])).toThrow(UsageError) +}) + +test('--json and --verbose toggle their booleans; defaults are false', () => { + const on = parseBridgeArgs(['--mode', 'acp', '--json', '--verbose', '--', 'x']) + expect(on.json).toBe(true) + expect(on.verbose).toBe(true) + const off = parseBridgeArgs(['--mode', 'acp', '--', 'x']) + expect(off.json).toBe(false) + expect(off.verbose).toBe(false) +}) + +test('default host=127.0.0.1 and port=0 when omitted', () => { + const parsed = parseBridgeArgs(['--mode', 'acp', '--', 'x']) + expect(parsed.host).toBe('127.0.0.1') + expect(parsed.port).toBe(0) +}) + +test('flag expecting a value at end-of-argv → UsageError', () => { + expect(() => parseBridgeArgs(['--mode'])).toThrow(UsageError) +}) + +test('a flag value that itself looks like a flag is treated as a missing value → UsageError', () => { + expect(() => parseBridgeArgs(['--host', '--port', '--', 'x'])).toThrow(UsageError) +}) + +test('flags may appear in any order before `--`', () => { + const parsed = parseBridgeArgs(['--verbose', '--port', '3000', '--mode', 'mcp', '--json', '--', 'srv']) + expect(parsed.mode).toBe('mcp') + expect(parsed.port).toBe(3000) + expect(parsed.verbose).toBe(true) + expect(parsed.json).toBe(true) +}) diff --git a/thunderbolt-stdio-bridge/src/child.js b/zeus/src/child.js similarity index 100% rename from thunderbolt-stdio-bridge/src/child.js rename to zeus/src/child.js diff --git a/thunderbolt-stdio-bridge/src/child.test.js b/zeus/src/child.test.js similarity index 100% rename from thunderbolt-stdio-bridge/src/child.test.js rename to zeus/src/child.test.js diff --git a/thunderbolt-stdio-bridge/src/errors.js b/zeus/src/errors.js similarity index 100% rename from thunderbolt-stdio-bridge/src/errors.js rename to zeus/src/errors.js diff --git a/thunderbolt-stdio-bridge/src/errors.test.js b/zeus/src/errors.test.js similarity index 100% rename from thunderbolt-stdio-bridge/src/errors.test.js rename to zeus/src/errors.test.js diff --git a/thunderbolt-stdio-bridge/src/log.js b/zeus/src/log.js similarity index 100% rename from thunderbolt-stdio-bridge/src/log.js rename to zeus/src/log.js diff --git a/thunderbolt-stdio-bridge/src/log.test.js b/zeus/src/log.test.js similarity index 100% rename from thunderbolt-stdio-bridge/src/log.test.js rename to zeus/src/log.test.js diff --git a/thunderbolt-stdio-bridge/src/mcp-multiplexer.js b/zeus/src/mcp-multiplexer.js similarity index 100% rename from thunderbolt-stdio-bridge/src/mcp-multiplexer.js rename to zeus/src/mcp-multiplexer.js diff --git a/thunderbolt-stdio-bridge/src/mcp-multiplexer.test.js b/zeus/src/mcp-multiplexer.test.js similarity index 100% rename from thunderbolt-stdio-bridge/src/mcp-multiplexer.test.js rename to zeus/src/mcp-multiplexer.test.js diff --git a/thunderbolt-stdio-bridge/src/mcp-server.integration.test.js b/zeus/src/mcp-server.integration.test.js similarity index 100% rename from thunderbolt-stdio-bridge/src/mcp-server.integration.test.js rename to zeus/src/mcp-server.integration.test.js diff --git a/thunderbolt-stdio-bridge/src/mcp-server.js b/zeus/src/mcp-server.js similarity index 100% rename from thunderbolt-stdio-bridge/src/mcp-server.js rename to zeus/src/mcp-server.js diff --git a/thunderbolt-stdio-bridge/src/mcp-server.test.js b/zeus/src/mcp-server.test.js similarity index 100% rename from thunderbolt-stdio-bridge/src/mcp-server.test.js rename to zeus/src/mcp-server.test.js diff --git a/thunderbolt-stdio-bridge/src/relay.js b/zeus/src/relay.js similarity index 100% rename from thunderbolt-stdio-bridge/src/relay.js rename to zeus/src/relay.js diff --git a/thunderbolt-stdio-bridge/src/relay.test.js b/zeus/src/relay.test.js similarity index 100% rename from thunderbolt-stdio-bridge/src/relay.test.js rename to zeus/src/relay.test.js diff --git a/thunderbolt-stdio-bridge/src/server.js b/zeus/src/server.js similarity index 100% rename from thunderbolt-stdio-bridge/src/server.js rename to zeus/src/server.js diff --git a/thunderbolt-stdio-bridge/src/server.test.js b/zeus/src/server.test.js similarity index 100% rename from thunderbolt-stdio-bridge/src/server.test.js rename to zeus/src/server.test.js diff --git a/thunderbolt-stdio-bridge/src/tunnel.js b/zeus/src/tunnel.js similarity index 100% rename from thunderbolt-stdio-bridge/src/tunnel.js rename to zeus/src/tunnel.js diff --git a/thunderbolt-stdio-bridge/src/tunnel.test.js b/zeus/src/tunnel.test.js similarity index 100% rename from thunderbolt-stdio-bridge/src/tunnel.test.js rename to zeus/src/tunnel.test.js diff --git a/thunderbolt-stdio-bridge/src/util.js b/zeus/src/util.js similarity index 100% rename from thunderbolt-stdio-bridge/src/util.js rename to zeus/src/util.js diff --git a/thunderbolt-stdio-bridge/src/util.test.js b/zeus/src/util.test.js similarity index 100% rename from thunderbolt-stdio-bridge/src/util.test.js rename to zeus/src/util.test.js From d359c751ab1ef6b32a694c97333e4b5b13994b13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 25 Jun 2026 17:48:28 -0300 Subject: [PATCH 13/45] refactor(THU-601): point app connect flow and in-app tutorial at zeus bridge --- e2e/acp-agents-catalog.spec.ts | 4 +-- src/acp/transports/index.test.ts | 2 +- src/acp/transports/index.ts | 2 +- src/acp/transports/is-loopback.ts | 2 +- .../agents/add-custom-agent-dialog.test.tsx | 4 +-- .../agents/add-custom-agent-dialog.tsx | 6 ++-- .../agents/bridge-connect-dialog.test.tsx | 8 ++--- .../settings/agents/bridge-connect-dialog.tsx | 10 +++--- .../settings/agents/copyable-command.test.tsx | 4 +-- src/lib/agent-bridge-command.test.ts | 12 +++---- src/lib/agent-bridge-command.ts | 32 +++++++++---------- src/lib/mcp-transport.ts | 4 +-- 12 files changed, 43 insertions(+), 47 deletions(-) diff --git a/e2e/acp-agents-catalog.spec.ts b/e2e/acp-agents-catalog.spec.ts index be56925f3..07bdfd9b3 100644 --- a/e2e/acp-agents-catalog.spec.ts +++ b/e2e/acp-agents-catalog.spec.ts @@ -54,7 +54,7 @@ test.describe('Agents catalog', () => { await expect(page.getByTestId('bridge-connect-dialog')).toBeVisible() // The composed bridge run command is shown for the user to copy. - await expect(page.getByText('npx thunderbolt-stdio-bridge --mode acp --', { exact: false })).toBeVisible() + await expect(page.getByText('zeus bridge --mode acp --', { exact: false })).toBeVisible() // The install one-liner is present too. await expect(page.getByText('curl -fsSL', { exact: false })).toBeVisible() @@ -76,7 +76,7 @@ test.describe('Agents catalog', () => { await page.getByTestId('agent-catalog-connect-goose').click() await expect(page.getByTestId('bridge-connect-dialog')).toBeVisible() await expect(page.getByText(/ships as a platform binary/i)).toBeVisible() - await expect(page.getByText('npx thunderbolt-stdio-bridge --mode acp --', { exact: false })).toHaveCount(0) + await expect(page.getByText('zeus bridge --mode acp --', { exact: false })).toHaveCount(0) expect(errors).toEqual([]) }) diff --git a/src/acp/transports/index.test.ts b/src/acp/transports/index.test.ts index 815465f4b..b3f7bfd53 100644 --- a/src/acp/transports/index.test.ts +++ b/src/acp/transports/index.test.ts @@ -225,7 +225,7 @@ describe('openTransport — agent-type routing', () => { }) it('remote-acp to a loopback bridge URL on Web connects natively (no proxy tunnel)', async () => { - // The stdio-bridge prints `ws://127.0.0.1:PORT`. Even on Web (where the proxy + // The bridge prints `ws://127.0.0.1:PORT`. Even on Web (where the proxy // toggle would normally force the universal-proxy path) a loopback target must // connect directly — the proxy hard-rejects loopback/private hosts (4003). const transport = await openTransport({ diff --git a/src/acp/transports/index.ts b/src/acp/transports/index.ts index d6b58c2a2..bf2cb5482 100644 --- a/src/acp/transports/index.ts +++ b/src/acp/transports/index.ts @@ -109,7 +109,7 @@ const resolveWebSocketFactory = (inputs: OpenTransportInputs): WebSocketFactory if (inputs.agentType === 'managed-acp') { return resolveManagedAcpFactory(inputs) } - // A loopback target is the local stdio-bridge (it prints `ws://127.0.0.1:PORT`). + // A loopback target is the local bridge (it prints `ws://127.0.0.1:PORT`). // Connect with a plain native WebSocket regardless of platform or proxy toggle: // the universal proxy hard-rejects loopback/private hosts (4003), and the bridge // is a trusted same-machine process needing no bearer. This carve-out precedes diff --git a/src/acp/transports/is-loopback.ts b/src/acp/transports/is-loopback.ts index 5f9f58e21..bf2fa65ac 100644 --- a/src/acp/transports/is-loopback.ts +++ b/src/acp/transports/is-loopback.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * Loopback classification for the stdio-bridge connect flow. + * Loopback classification for the bridge connect flow. * * The bridge binds to `127.0.0.1` and prints `ws://127.0.0.1:PORT` (ACP) or * `http://127.0.0.1:PORT/mcp` (MCP) on its STDERR. The app connects to that diff --git a/src/components/settings/agents/add-custom-agent-dialog.test.tsx b/src/components/settings/agents/add-custom-agent-dialog.test.tsx index 3294a6247..5e8a91e45 100644 --- a/src/components/settings/agents/add-custom-agent-dialog.test.tsx +++ b/src/components/settings/agents/add-custom-agent-dialog.test.tsx @@ -311,9 +311,9 @@ describe('AddCustomAgentDialog — local bridge hint', () => { />, ) - it('shows the stdio-bridge hint by default (no URL entered)', () => { + it('shows the zeus bridge hint by default (no URL entered)', () => { renderDialog() - expect(screen.getByText(/thunderbolt-stdio-bridge/i)).toBeInTheDocument() + expect(screen.getByText(/zeus bridge/i)).toBeInTheDocument() expect(screen.queryByTestId('agent-url-loopback-hint')).not.toBeInTheDocument() }) diff --git a/src/components/settings/agents/add-custom-agent-dialog.tsx b/src/components/settings/agents/add-custom-agent-dialog.tsx index bffc2b645..925bf5192 100644 --- a/src/components/settings/agents/add-custom-agent-dialog.tsx +++ b/src/components/settings/agents/add-custom-agent-dialog.tsx @@ -174,7 +174,7 @@ export const AddCustomAgentDialog = ({ const trimmedUrl = state.url.trim() const trimmedDescription = state.description.trim() const validation = validateAgentUrl(trimmedUrl, isIos) - // A loopback URL is a local stdio-bridge endpoint — reassure the user it + // A loopback URL is a local bridge endpoint — reassure the user it // connects directly (no cloud proxy). Derived during render from the field. const isLoopbackTarget = trimmedUrl.length > 0 && isLoopbackUrl(trimmedUrl) // Surface an invalid-URL error at render time (once the field is non-empty) @@ -267,8 +267,8 @@ export const AddCustomAgentDialog = ({

) : (

- Running an agent locally? Use the thunderbolt-stdio-bridge and paste - its ws://127.0.0.1:PORT address. + Running an agent locally? Use zeus bridge and paste its{' '} + ws://127.0.0.1:PORT address.

)}
diff --git a/src/components/settings/agents/bridge-connect-dialog.test.tsx b/src/components/settings/agents/bridge-connect-dialog.test.tsx index 9065ffbce..4fc53b8c9 100644 --- a/src/components/settings/agents/bridge-connect-dialog.test.tsx +++ b/src/components/settings/agents/bridge-connect-dialog.test.tsx @@ -45,9 +45,7 @@ describe('BridgeConnectDialog — npx agent', () => { expect(screen.getByText(/connect gemini via bridge/i)).toBeInTheDocument() // Install command and the bridge run command both appear. expect(screen.getByText(/curl -fsSL/)).toBeInTheDocument() - expect( - screen.getByText('thunderbolt-stdio-bridge --mode acp -- npx @google/gemini-cli@0.46.0 --acp'), - ).toBeInTheDocument() + expect(screen.getByText('zeus bridge --mode acp -- npx @google/gemini-cli@0.46.0 --acp')).toBeInTheDocument() // Step 3 directs the user to Add custom agent. expect(screen.getByText(/add custom agent/i)).toBeInTheDocument() }) @@ -59,9 +57,7 @@ describe('BridgeConnectDialog — npx agent', () => { fireEvent.click(screen.getByTestId('copyable-command-copy-run')) }) - expect(writeTextMock).toHaveBeenCalledWith( - 'thunderbolt-stdio-bridge --mode acp -- npx @google/gemini-cli@0.46.0 --acp', - ) + expect(writeTextMock).toHaveBeenCalledWith('zeus bridge --mode acp -- npx @google/gemini-cli@0.46.0 --acp') }) it('copies the install command to the clipboard', async () => { diff --git a/src/components/settings/agents/bridge-connect-dialog.tsx b/src/components/settings/agents/bridge-connect-dialog.tsx index 8ecf88a0d..0c45085f7 100644 --- a/src/components/settings/agents/bridge-connect-dialog.tsx +++ b/src/components/settings/agents/bridge-connect-dialog.tsx @@ -67,11 +67,11 @@ const BinaryFallback = ({ entry }: { entry: RegistryEntry }) => { } /** - * Walks the user through connecting a catalogue agent via the local - * `thunderbolt-stdio-bridge`: install the bridge, run it wrapping the agent's - * CLI, then add the loopback URL it prints as a custom agent. Binary-only agents - * have no composable launch, so the dialog renders a fallback that points at the - * agent's own docs instead. + * Walks the user through connecting a catalogue agent via the local `zeus + * bridge`: install the binary, run the bridge wrapping the agent's CLI, then add + * the loopback URL it prints as a custom agent. Binary-only agents have no + * composable launch, so the dialog renders a fallback that points at the agent's + * own docs instead. */ export const BridgeConnectDialog = ({ entry, open, onOpenChange }: BridgeConnectDialogProps) => { const bridgeCommand = composeBridgeCommand(entry, window.location.origin) diff --git a/src/components/settings/agents/copyable-command.test.tsx b/src/components/settings/agents/copyable-command.test.tsx index 4d769baf2..0655ea5af 100644 --- a/src/components/settings/agents/copyable-command.test.tsx +++ b/src/components/settings/agents/copyable-command.test.tsx @@ -20,8 +20,8 @@ afterEach(() => { describe('CopyableCommand', () => { it('renders the command text', () => { - render() - expect(screen.getByText('npx thunderbolt-stdio-bridge --help')).toBeInTheDocument() + render() + expect(screen.getByText('zeus bridge --help')).toBeInTheDocument() }) it('copies the command and flips the button label to Copied', async () => { diff --git a/src/lib/agent-bridge-command.test.ts b/src/lib/agent-bridge-command.test.ts index 353d9045e..b0f2b9612 100644 --- a/src/lib/agent-bridge-command.test.ts +++ b/src/lib/agent-bridge-command.test.ts @@ -54,27 +54,27 @@ describe('composeBridgeCommand', () => { it('wraps an npx launch in the bridge command (--mode acp), invoking the bare on-PATH binary', () => { const entry = entryWith({ npx: { package: '@google/gemini-cli@0.46.0', args: ['--acp'] } }) const command = composeBridgeCommand(entry) - expect(command).toBe('thunderbolt-stdio-bridge --mode acp -- npx @google/gemini-cli@0.46.0 --acp') - expect(command?.startsWith('thunderbolt-stdio-bridge ')).toBe(true) + expect(command).toBe('zeus bridge --mode acp -- npx @google/gemini-cli@0.46.0 --acp') + expect(command?.startsWith('zeus bridge ')).toBe(true) expect(command?.startsWith('npx')).toBe(false) }) it('wraps a uvx launch in the bridge command', () => { const entry = entryWith({ uvx: { package: 'fast-agent', args: ['acp'] } }) - expect(composeBridgeCommand(entry)).toBe('thunderbolt-stdio-bridge --mode acp -- uvx fast-agent acp') + expect(composeBridgeCommand(entry)).toBe('zeus bridge --mode acp -- uvx fast-agent acp') }) it('adds --allow-origin for a non-loopback app origin (production web)', () => { const entry = entryWith({ npx: { package: '@google/gemini-cli@0.46.0', args: ['--acp'] } }) expect(composeBridgeCommand(entry, 'https://app.thunderbird.net')).toBe( - "thunderbolt-stdio-bridge --mode acp --allow-origin 'https://app.thunderbird.net' -- npx @google/gemini-cli@0.46.0 --acp", + "zeus bridge --mode acp --allow-origin 'https://app.thunderbird.net' -- npx @google/gemini-cli@0.46.0 --acp", ) }) it('omits --allow-origin for a loopback app origin (default allowlist already accepts it)', () => { const entry = entryWith({ npx: { package: '@google/gemini-cli@0.46.0', args: ['--acp'] } }) const command = composeBridgeCommand(entry, 'http://localhost:1421') - expect(command).toBe('thunderbolt-stdio-bridge --mode acp -- npx @google/gemini-cli@0.46.0 --acp') + expect(command).toBe('zeus bridge --mode acp -- npx @google/gemini-cli@0.46.0 --acp') expect(command).not.toContain('--allow-origin') }) @@ -94,7 +94,7 @@ describe('composeInstallCommand', () => { it('returns the curl | bash one-liner for the bridge installer', () => { const command = composeInstallCommand() expect(command).toContain('curl -fsSL') - expect(command).toContain('thunderbolt-stdio-bridge/install.sh') + expect(command).toContain('zeus/install.sh') expect(command.endsWith('| bash')).toBe(true) }) }) diff --git a/src/lib/agent-bridge-command.ts b/src/lib/agent-bridge-command.ts index 731834c4f..b8c847e4a 100644 --- a/src/lib/agent-bridge-command.ts +++ b/src/lib/agent-bridge-command.ts @@ -3,12 +3,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * Shell-command composers for the stdio-bridge connect flow. + * Shell-command composers for the bridge connect flow. * * A catalogue agent is a local CLI (npx / uvx / binary). To reach it from the - * app the user runs `thunderbolt-stdio-bridge`, which spawns the agent's CLI and - * exposes it over a loopback WebSocket (`ws://127.0.0.1:PORT`). These helpers - * build the three copyable commands the connect dialog walks the user through: + * app the user runs `zeus bridge`, which spawns the agent's CLI and exposes it + * over a loopback WebSocket (`ws://127.0.0.1:PORT`). These helpers build the + * three copyable commands the connect dialog walks the user through: * * 1. install the bridge (`composeInstallCommand`) * 2. run the bridge wrapping the agent (`composeBridgeCommand`) @@ -22,14 +22,14 @@ import { isLoopbackUrl } from '@/acp/transports/is-loopback' import type { RegistryEntry } from '@/types/registry' -/** The command name the app's `install.sh` installs onto PATH. */ -const bridgeBin = 'thunderbolt-stdio-bridge' +/** The command name the app's `install.sh` installs onto PATH. The bridge is a + * subcommand of this binary (`zeus bridge …`). */ +const bridgeBin = 'zeus' -/** Canonical one-line installer (curl | bash) — matches - * `thunderbolt-stdio-bridge/install.sh`'s documented invocation. The bridge - * drops onto the npm global bin as a node-shebang script. */ +/** Canonical one-line installer (curl | bash) — matches `zeus/install.sh`'s + * documented invocation. The binary drops onto the user's PATH. */ const installCommand = - 'curl -fsSL https://raw.githubusercontent.com/thunderbird/thunderbolt/main/thunderbolt-stdio-bridge/install.sh | bash' + 'curl -fsSL https://raw.githubusercontent.com/thunderbird/thunderbolt/main/zeus/install.sh | bash' /** * The shell fragment that launches the agent's own CLI, e.g. @@ -72,11 +72,11 @@ const needsAllowOrigin = (origin: string | undefined): origin is string => { /** * The full bridge command for an agent: - * `thunderbolt-stdio-bridge --mode acp -- `. The bridge is the bare - * binary `install.sh` drops on PATH — invoke it directly (no `npx`, which would - * hit the registry since the bridge is never published to npm). Returns `null` - * when the agent only ships a binary distribution (no composable launch - * fragment), so the dialog can render its binary fallback instead. + * `zeus bridge --mode acp -- `. `zeus` is the bare binary `install.sh` + * drops on PATH — invoke it directly (no `npx`, which would hit the registry + * since the binary is never published to npm). Returns `null` when the agent + * only ships a binary distribution (no composable launch fragment), so the + * dialog can render its binary fallback instead. * * When `origin` is a non-loopback app origin (production web), the bridge's * default loopback-only Origin allowlist would reject the browser's WS upgrade, @@ -91,5 +91,5 @@ export const composeBridgeCommand = (entry: RegistryEntry, origin?: string): str return null } const allowOrigin = needsAllowOrigin(origin) ? `--allow-origin '${origin}' ` : '' - return `${bridgeBin} --mode acp ${allowOrigin}-- ${launch}` + return `${bridgeBin} bridge --mode acp ${allowOrigin}-- ${launch}` } diff --git a/src/lib/mcp-transport.ts b/src/lib/mcp-transport.ts index a6502a28b..c044819f9 100644 --- a/src/lib/mcp-transport.ts +++ b/src/lib/mcp-transport.ts @@ -65,7 +65,7 @@ export type McpFetch = (input: string | URL, init?: RequestInit) => Promise Date: Thu, 25 Jun 2026 17:48:33 -0300 Subject: [PATCH 14/45] ci(THU-601): rename bridge release workflow to zeus-release --- ...io-bridge-release.yml => zeus-release.yml} | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) rename .github/workflows/{stdio-bridge-release.yml => zeus-release.yml} (63%) diff --git a/.github/workflows/stdio-bridge-release.yml b/.github/workflows/zeus-release.yml similarity index 63% rename from .github/workflows/stdio-bridge-release.yml rename to .github/workflows/zeus-release.yml index a20423136..1d9d7eaad 100644 --- a/.github/workflows/stdio-bridge-release.yml +++ b/.github/workflows/zeus-release.yml @@ -2,23 +2,23 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. -# Build, smoke-test, and (on a tag) publish the thunderbolt-stdio-bridge CLI. +# Build, smoke-test, and (on a tag) publish the zeus CLI. # -# On every push/PR touching thunderbolt-stdio-bridge/** we bundle bridge.cjs and -# prove all three entry points work: --help exits 0, the ACP face prints its -# `listening` banner, the MCP face prints its `mcp-listening` banner. On a -# `stdio-bridge-v*` tag we additionally attach bridge.cjs to the tag's GitHub -# Release, which install.sh fetches via releases/download/stdio-bridge-v/. +# On every push/PR touching zeus/** we bundle zeus.cjs and prove all three +# entry points work: --help exits 0, the ACP face prints its `listening` +# banner, the MCP face prints its `mcp-listening` banner. On a `zeus-v*` tag we +# additionally attach zeus.cjs to the tag's GitHub Release, which install.sh +# fetches via releases/download/zeus-v/. -name: stdio-bridge release +name: zeus release on: push: branches: [main] - paths: ['thunderbolt-stdio-bridge/**'] - tags: ['stdio-bridge-v*'] + paths: ['zeus/**'] + tags: ['zeus-v*'] pull_request: - paths: ['thunderbolt-stdio-bridge/**'] + paths: ['zeus/**'] workflow_dispatch: concurrency: @@ -30,15 +30,15 @@ permissions: defaults: run: - working-directory: thunderbolt-stdio-bridge + working-directory: zeus jobs: build: - name: Build & smoke-test bridge.cjs + name: Build & smoke-test zeus.cjs runs-on: ubuntu-24.04 # The tag job needs write to publish the release; everything else stays read. permissions: - contents: ${{ startsWith(github.ref, 'refs/tags/stdio-bridge-v') && 'write' || 'read' }} + contents: ${{ startsWith(github.ref, 'refs/tags/zeus-v') && 'write' || 'read' }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -53,18 +53,18 @@ jobs: - run: bun install --frozen-lockfile - run: bun run build - # bridge.cjs parses and --help exits 0 (no child, no framing at risk). + # zeus.cjs parses and --help exits 0 (no child, no framing at risk). - name: Smoke --help run: | - node dist/bridge.cjs --help | grep -q "thunderbolt-stdio-bridge" - node dist/bridge.cjs --version + node dist/zeus.cjs bridge --help | grep -q "zeus" + node dist/zeus.cjs --version # ACP face: bind the WS face over a trivial `cat` child and confirm the # readiness banner reaches stderr (token: `listening`). Capture stderr to a # file, poll until the banner lands, then kill the still-running bridge. - name: Smoke ACP (listening) run: | - node dist/bridge.cjs --mode acp --json -- cat 2>acp.log & + node dist/zeus.cjs bridge --mode acp --json -- cat 2>acp.log & pid=$! for _ in $(seq 1 50); do grep -q '"event":"listening"' acp.log && break @@ -78,7 +78,7 @@ jobs: # the readiness banner reaches stderr (token: `mcp-listening`). - name: Smoke MCP (mcp-listening) run: | - node dist/bridge.cjs --mode mcp --json -- \ + node dist/zeus.cjs bridge --mode mcp --json -- \ node node_modules/@modelcontextprotocol/server-everything/dist/index.js stdio 2>mcp.log & pid=$! for _ in $(seq 1 50); do @@ -89,15 +89,15 @@ jobs: grep -q '"event":"mcp-listening"' mcp.log grep -q 'http://127.0.0.1:' mcp.log - - name: Publish bridge.cjs (+ sha256) to the tag's GitHub Release - if: startsWith(github.ref, 'refs/tags/stdio-bridge-v') + - name: Publish zeus.cjs (+ sha256) to the tag's GitHub Release + if: startsWith(github.ref, 'refs/tags/zeus-v') env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Emit the checksum with a bare `bridge.cjs` basename (cd into dist) so the + # Emit the checksum with a bare `zeus.cjs` basename (cd into dist) so the # published .sha256 is in plain `shasum -a 256 -c` format; install.sh # verifies the downloaded bundle against it before installing. run: | - ( cd dist && shasum -a 256 bridge.cjs > bridge.cjs.sha256 ) + ( cd dist && shasum -a 256 zeus.cjs > zeus.cjs.sha256 ) gh release create "${GITHUB_REF_NAME}" \ - dist/bridge.cjs dist/bridge.cjs.sha256 \ + dist/zeus.cjs dist/zeus.cjs.sha256 \ --title "${GITHUB_REF_NAME}" --generate-notes From db879ab478bf569d0cfe89bf9d859b8d8f4eec08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 25 Jun 2026 17:54:08 -0300 Subject: [PATCH 15/45] refactor(THU-601): dispatch zeus subcommands by name so future commands slot in --- zeus/bin/cli.js | 113 +++++++++++++++++++++++++++++------------------ zeus/src/args.js | 2 +- 2 files changed, 70 insertions(+), 45 deletions(-) diff --git a/zeus/bin/cli.js b/zeus/bin/cli.js index ffb39e7f1..160f2785e 100644 --- a/zeus/bin/cli.js +++ b/zeus/bin/cli.js @@ -3,10 +3,11 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. -// Composition root. Thin wiring only: parse argv, build the logger, validate, -// then dispatch to the ACP or MCP face, install signal handlers, and translate -// every outcome into a sysexits exit code. Every external collaborator is -// injectable via a single `deps` object so the whole root is testable with no +// Composition root. Thin wiring only: parse argv, short-circuit error/help/ +// version, then dispatch to the resolved subcommand. The `bridge` command builds +// the logger, installs signal handlers, starts the ACP or MCP face, and +// translates every outcome into a sysexits exit code. Every external collaborator +// is injectable via a single `deps` object so the whole root is testable with no // real sockets. 'use strict' @@ -54,25 +55,23 @@ Options: -h, --help print this help and exit -V, --version print the version and exit` +/** Usage text keyed by the parser's `help` intent (`'root'` | the command name). */ +const HELP = { root: ROOT_HELP_TEXT, bridge: BRIDGE_HELP_TEXT } + /** - * Run the bridge CLI. Returns once the process outcome is decided; all exits go - * through the injected `exit` so tests assert the code without terminating. + * Run the `bridge` subcommand: build the logger, warn on insecure flags, wire the + * never-orphan lifecycle (signal handlers + an uncaught-error backstop), and start + * the ACP or MCP face. Returns once the outcome is decided; the process is kept + * alive by the open server/sockets until a signal or child exit closes the face. * - * @param {Object} [opts] - * @param {string[]} [opts.argv] - argv without node/script (process.argv.slice(2)). - * @param {NodeJS.WritableStream} [opts.stdout] - help/version sink only. - * @param {NodeJS.WritableStream} [opts.stderr] - all diagnostics + banner. - * @param {(code: number) => void} [opts.exit] - * @param {Object} [opts.deps] - injectable { startBridge, startMcpFace, startTunnel, generateBearer, makeLogger, on, removeListener }. + * @param {import('../src/args').ParsedArgs} parsed - resolved bridge options. + * @param {Object} io + * @param {NodeJS.WritableStream} io.stderr - all diagnostics + banner. + * @param {(code: number) => void} io.exit + * @param {Object} io.deps - injectable { startBridge, startMcpFace, startTunnel, generateBearer, makeLogger, on, removeListener }. * @returns {Promise} */ -const run = async ({ - argv = process.argv.slice(2), - stdout = process.stdout, - stderr = process.stderr, - exit = process.exit, - deps = {}, -} = {}) => { +const runBridge = async (parsed, { stderr, exit, deps }) => { const _startBridge = deps.startBridge ?? startBridge const _startMcpFace = deps.startMcpFace ?? startMcpFace const _startTunnel = deps.startTunnel ?? startTunnel @@ -81,28 +80,6 @@ const run = async ({ const onSignal = deps.on ?? process.on.bind(process) const offSignal = deps.removeListener ?? process.removeListener.bind(process) - // Parse argv. Help/version short-circuit to stdout (no child, no framing). - const parsed = (() => { - try { - return parseArgs(argv) - } catch (err) { - return { error: err } - } - })() - - if (parsed.error) { - stderr.write(`${toMessage(parsed.error)}\n`) - return exit(toExitCode(parsed.error)) - } - if (parsed.help) { - stdout.write(`${parsed.help === 'root' ? ROOT_HELP_TEXT : BRIDGE_HELP_TEXT}\n`) - return exit(EX.OK) - } - if (parsed.version) { - stdout.write(`${BRIDGE_VERSION}\n`) - return exit(EX.OK) - } - const logger = _makeLogger({ json: parsed.json, verbose: parsed.verbose, sink: stderr }) // Emit insecure-flag warnings before binding anything. @@ -169,9 +146,7 @@ const run = async ({ }) live.face = face // ACP face resolves on child exit via its own close(); cli derives the code - // from the child exit propagated by server.js. The face stays alive until a - // signal or child exit closes it; run() returns and the process is kept - // alive by the open server/sockets. + // from the child exit propagated by server.js. return } @@ -204,6 +179,56 @@ const run = async ({ } } +/** + * CLI composition root. Parse argv, short-circuit error/help/version, then + * dispatch to the resolved subcommand. All exits go through the injected `exit` + * so tests assert the code without terminating. + * + * @param {Object} [opts] + * @param {string[]} [opts.argv] - argv without node/script (process.argv.slice(2)). + * @param {NodeJS.WritableStream} [opts.stdout] - help/version sink only. + * @param {NodeJS.WritableStream} [opts.stderr] - all diagnostics + banner. + * @param {(code: number) => void} [opts.exit] + * @param {Object} [opts.deps] - injectable collaborators forwarded to the subcommand. + * @returns {Promise} + */ +const run = async ({ + argv = process.argv.slice(2), + stdout = process.stdout, + stderr = process.stderr, + exit = process.exit, + deps = {}, +} = {}) => { + const parsed = (() => { + try { + return parseArgs(argv) + } catch (err) { + return { error: err } + } + })() + + if (parsed.error) { + stderr.write(`${toMessage(parsed.error)}\n`) + return exit(toExitCode(parsed.error)) + } + if (parsed.help) { + stdout.write(`${HELP[parsed.help]}\n`) + return exit(EX.OK) + } + if (parsed.version) { + stdout.write(`${BRIDGE_VERSION}\n`) + return exit(EX.OK) + } + + // Dispatch the resolved subcommand. The parser rejects unknown commands, so + // `parsed.command` is always a known case here; a future `zeus ` is a new + // `case` + a `run` — the bridge path stays untouched. + switch (parsed.command) { + case 'bridge': + return runBridge(parsed, { stderr, exit, deps }) + } +} + module.exports = { run } // Module side-effect entry: run when invoked as the program (not when required diff --git a/zeus/src/args.js b/zeus/src/args.js index 02f9951d2..48cdbb65c 100644 --- a/zeus/src/args.js +++ b/zeus/src/args.js @@ -106,7 +106,7 @@ const parseArgs = (argv) => { if (token === '--version' || token === '-V') return { version: true } if (token === 'bridge') { const parsed = parseBridgeArgs(rest) - if ('help' in parsed || 'version' in parsed) return parsed + if (parsed.help || parsed.version) return parsed return { ...parsed, command: 'bridge' } } throw new UsageError(`unknown command: ${token}`) From dd26c7e417d2b98691e389777282c59dd22f7c74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 25 Jun 2026 18:04:36 -0300 Subject: [PATCH 16/45] fix(THU-601): pass child --help/--version past the -- delimiter to the agent --- zeus/src/args.js | 8 +++++--- zeus/src/args.test.js | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/zeus/src/args.js b/zeus/src/args.js index 48cdbb65c..c5eb14704 100644 --- a/zeus/src/args.js +++ b/zeus/src/args.js @@ -28,13 +28,15 @@ const looksLikeFlag = (token) => token !== undefined && token.startsWith('-') * @returns {ParsedArgs | { help: 'bridge' } | { version: true }} */ const parseBridgeArgs = (argv) => { - if (argv.includes('--help') || argv.includes('-h')) return { help: 'bridge' } - if (argv.includes('--version') || argv.includes('-V')) return { version: true } - const delimiterIndex = argv.indexOf('--') const flagArgs = delimiterIndex === -1 ? argv : argv.slice(0, delimiterIndex) const launch = delimiterIndex === -1 ? [] : argv.slice(delimiterIndex + 1) + // Help/version only count as zeus flags BEFORE `--`; a `--help`/`--version` in + // the child launch argv (after `--`) is the child's, passed through verbatim. + if (flagArgs.includes('--help') || flagArgs.includes('-h')) return { help: 'bridge' } + if (flagArgs.includes('--version') || flagArgs.includes('-V')) return { version: true } + /** @type {Partial} */ const opts = { host: '127.0.0.1', diff --git a/zeus/src/args.test.js b/zeus/src/args.test.js index abfb3dae3..a892974db 100644 --- a/zeus/src/args.test.js +++ b/zeus/src/args.test.js @@ -55,6 +55,15 @@ test('--mode acp -- node agent.js → mode acp, launch=[node, agent.js]', () => expect(parsed.launch).toEqual(['node', 'agent.js']) }) +test('--help/--version AFTER `--` belong to the child, not zeus', () => { + // The delimiter ends zeus's flags; a `--help` in the launch argv must pass + // through to the child verbatim, not short-circuit to bridge help. + const parsed = parseBridgeArgs(['--mode', 'acp', '--', 'node', 'agent.js', '--help']) + expect(parsed.help).toBeUndefined() + expect(parsed.launch).toEqual(['node', 'agent.js', '--help']) + expect(parseBridgeArgs(['--mode', 'mcp', '--', 'srv', '--version']).launch).toContain('--version') +}) + test('--mode mcp --tunnel -- srv → tunnel true', () => { expect(parseBridgeArgs(['--mode', 'mcp', '--tunnel', '--', 'srv']).tunnel).toBe(true) }) From 5ccfa7371e8f3176a7ab061adde8d05fafe5e479 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 25 Jun 2026 18:04:41 -0300 Subject: [PATCH 17/45] chore(THU-601): rename ZEUS_BIN_DIR, sync lockfile name, guard subcommand dispatch --- zeus/bin/cli.js | 5 +++++ zeus/bun.lock | 2 +- zeus/install.sh | 8 ++++---- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/zeus/bin/cli.js b/zeus/bin/cli.js index 160f2785e..53ab590d3 100644 --- a/zeus/bin/cli.js +++ b/zeus/bin/cli.js @@ -226,6 +226,11 @@ const run = async ({ switch (parsed.command) { case 'bridge': return runBridge(parsed, { stderr, exit, deps }) + default: + // Unreachable today (the parser only resolves known commands), but guards a + // future `zeus ` wired into the parser yet not here from silently + // hanging — run() returning without ever calling exit(). + throw new Error(`unhandled command: ${parsed.command}`) } } diff --git a/zeus/bun.lock b/zeus/bun.lock index a1ff67ce3..96254090f 100644 --- a/zeus/bun.lock +++ b/zeus/bun.lock @@ -3,7 +3,7 @@ "configVersion": 1, "workspaces": { "": { - "name": "thunderbolt-stdio-bridge", + "name": "zeus", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", "ws": "^8.18.0", diff --git a/zeus/install.sh b/zeus/install.sh index af74ee143..a38ee9b59 100755 --- a/zeus/install.sh +++ b/zeus/install.sh @@ -14,7 +14,7 @@ # curl -fsSL https://raw.githubusercontent.com/thunderbird/thunderbolt/main/zeus/install.sh | bash # # Pin a version: ... | bash -s -- 0.1.0 -# Custom bin dir: THUNDERBOLT_BIN_DIR=/opt/bin ... | bash +# Custom bin dir: ZEUS_BIN_DIR=/opt/bin ... | bash set -euo pipefail REPO="thunderbird/thunderbolt" @@ -26,13 +26,13 @@ command -v npm >/dev/null 2>&1 || { echo "error: npm is required (ships with nod # Install next to npm/npx (the npm global bin), or honor an explicit override. # Without an override, resolve the npm global prefix and refuse to proceed if it # is empty — never fall back to a bare `/bin`, which would be wrong and unsafe. -if [ -n "${THUNDERBOLT_BIN_DIR:-}" ]; then - BIN_DIR="$THUNDERBOLT_BIN_DIR" +if [ -n "${ZEUS_BIN_DIR:-}" ]; then + BIN_DIR="$ZEUS_BIN_DIR" else prefix="$(npm prefix -g 2>/dev/null)" [ -n "$prefix" ] && [ -d "$prefix" ] || { echo "error: could not resolve the npm global prefix (npm prefix -g)." >&2 - echo " set THUNDERBOLT_BIN_DIR=/path/to/bin and re-run." >&2 + echo " set ZEUS_BIN_DIR=/path/to/bin and re-run." >&2 exit 1 } BIN_DIR="$prefix/bin" From fd516700bc7898fb73feed4cce7d83a78167f206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 25 Jun 2026 18:16:56 -0300 Subject: [PATCH 18/45] feat(THU-601): add a connect-local-MCP-server-via-bridge flow to the MCP screen --- .../mcp-bridge-connect-dialog.test.tsx | 64 +++ .../mcp-servers/mcp-bridge-connect-dialog.tsx | 102 +++++ src/lib/agent-bridge-command.test.ts | 34 +- src/lib/agent-bridge-command.ts | 43 +- src/settings/mcp-servers.tsx | 412 ++++++++++-------- 5 files changed, 447 insertions(+), 208 deletions(-) create mode 100644 src/components/settings/mcp-servers/mcp-bridge-connect-dialog.test.tsx create mode 100644 src/components/settings/mcp-servers/mcp-bridge-connect-dialog.tsx diff --git a/src/components/settings/mcp-servers/mcp-bridge-connect-dialog.test.tsx b/src/components/settings/mcp-servers/mcp-bridge-connect-dialog.test.tsx new file mode 100644 index 000000000..196e48774 --- /dev/null +++ b/src/components/settings/mcp-servers/mcp-bridge-connect-dialog.test.tsx @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import '@testing-library/jest-dom' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, mock } from 'bun:test' +import { McpBridgeConnectDialog } from './mcp-bridge-connect-dialog' + +const writeTextMock = mock((_text: string) => Promise.resolve()) +Object.defineProperty(navigator, 'clipboard', { + value: { writeText: writeTextMock }, + configurable: true, +}) + +afterEach(() => { + cleanup() + writeTextMock.mockClear() +}) + +describe('McpBridgeConnectDialog', () => { + it('renders the install step and waits for a command before showing the run command', () => { + render( {}} />) + + expect(screen.getByText(/connect a local mcp server via bridge/i)).toBeInTheDocument() + expect(screen.getByText(/curl -fsSL/)).toBeInTheDocument() + // No run command until the user enters their stdio launch command. + expect(screen.queryByTestId('copyable-command-copy-run')).not.toBeInTheDocument() + expect(screen.getByText(/enter the command above/i)).toBeInTheDocument() + }) + + it('composes the --mode mcp run command from the typed stdio command', () => { + render( {}} />) + + fireEvent.change(screen.getByPlaceholderText(/server-everything stdio/i), { + target: { value: 'npx @modelcontextprotocol/server-everything stdio' }, + }) + + expect( + screen.getByText('zeus bridge --mode mcp -- npx @modelcontextprotocol/server-everything stdio'), + ).toBeInTheDocument() + }) + + it('copies the composed run command to the clipboard', async () => { + render( {}} />) + + fireEvent.change(screen.getByPlaceholderText(/server-everything stdio/i), { + target: { value: 'uvx mcp-server' }, + }) + await act(async () => { + fireEvent.click(screen.getByTestId('copyable-command-copy-run')) + }) + + expect(writeTextMock).toHaveBeenCalledWith('zeus bridge --mode mcp -- uvx mcp-server') + }) + + it('invokes onOpenChange(false) when Done is clicked', () => { + const onOpenChange = mock(() => {}) + render() + + fireEvent.click(screen.getByRole('button', { name: /done/i })) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) +}) diff --git a/src/components/settings/mcp-servers/mcp-bridge-connect-dialog.tsx b/src/components/settings/mcp-servers/mcp-bridge-connect-dialog.tsx new file mode 100644 index 000000000..7b38d8f62 --- /dev/null +++ b/src/components/settings/mcp-servers/mcp-bridge-connect-dialog.tsx @@ -0,0 +1,102 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { useState, type ReactNode } from 'react' +import { Button } from '@/components/ui/button' +import { Dialog } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + ResponsiveModalContentComposable, + ResponsiveModalDescription, + ResponsiveModalHeader, + ResponsiveModalTitle, +} from '@/components/ui/responsive-modal' +import { composeInstallCommand, composeMcpBridgeCommand } from '@/lib/agent-bridge-command' +import { CopyableCommand } from '@/components/settings/agents/copyable-command' + +type McpBridgeConnectDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void +} + +/** A numbered step with a title and body. */ +const Step = ({ index, title, children }: { index: number; title: string; children: ReactNode }) => ( +
+ + {index} + +
+

{title}

+ {children} +
+
+) + +/** + * Walks the user through exposing a local stdio MCP server through the `zeus + * bridge`: install the binary, run the bridge wrapping the server's launch + * command, then add the loopback `http://127.0.0.1:PORT/mcp` URL it prints as a + * remote server. Unlike the ACP agent catalogue there's no preset entry, so the + * user supplies the stdio launch command and the run command is composed live. + */ +export const McpBridgeConnectDialog = ({ open, onOpenChange }: McpBridgeConnectDialogProps) => { + const [command, setCommand] = useState('') + const bridgeCommand = composeMcpBridgeCommand(command, window.location.origin) + + return ( + + + + Connect a local MCP server via bridge + + Run a local stdio MCP server and expose it to Thunderbolt over a loopback HTTP endpoint. + + +
+ + + + + + setCommand(e.target.value)} + /> + {bridgeCommand ? ( + <> +

+ It prints an{' '} + http://127.0.0.1:PORT/mcp address + and stays running. +

+ + + ) : ( +

+ Enter the command above to build the runnable bridge command. +

+ )} +
+ +

+ Use "Add MCP Server" and paste the printed{' '} + http://127.0.0.1:PORT/mcp address as + the URL (Transport: HTTP). +

+
+
+
+ +
+
+
+ ) +} diff --git a/src/lib/agent-bridge-command.test.ts b/src/lib/agent-bridge-command.test.ts index b0f2b9612..79e52056c 100644 --- a/src/lib/agent-bridge-command.test.ts +++ b/src/lib/agent-bridge-command.test.ts @@ -4,7 +4,12 @@ import { describe, expect, it } from 'bun:test' import type { RegistryDistribution, RegistryEntry } from '@/types/registry' -import { composeBridgeCommand, composeInstallCommand, composeLaunchCommand } from './agent-bridge-command' +import { + composeBridgeCommand, + composeInstallCommand, + composeLaunchCommand, + composeMcpBridgeCommand, +} from './agent-bridge-command' const entryWith = (distribution: RegistryDistribution): RegistryEntry => ({ id: 'test', @@ -90,6 +95,33 @@ describe('composeBridgeCommand', () => { }) }) +describe('composeMcpBridgeCommand', () => { + it('wraps a stdio command in the bridge command (--mode mcp)', () => { + const command = composeMcpBridgeCommand('npx @modelcontextprotocol/server-everything stdio') + expect(command).toBe('zeus bridge --mode mcp -- npx @modelcontextprotocol/server-everything stdio') + expect(command?.startsWith('zeus bridge --mode mcp')).toBe(true) + }) + + it('trims surrounding whitespace from the command', () => { + expect(composeMcpBridgeCommand(' uvx mcp-server ')).toBe('zeus bridge --mode mcp -- uvx mcp-server') + }) + + it('returns null for a blank command', () => { + expect(composeMcpBridgeCommand('')).toBeNull() + expect(composeMcpBridgeCommand(' ')).toBeNull() + }) + + it('adds --allow-origin for a non-loopback app origin (production web)', () => { + expect(composeMcpBridgeCommand('npx srv', 'https://app.thunderbird.net')).toBe( + "zeus bridge --mode mcp --allow-origin 'https://app.thunderbird.net' -- npx srv", + ) + }) + + it('omits --allow-origin for a loopback app origin', () => { + expect(composeMcpBridgeCommand('npx srv', 'http://localhost:1421')).not.toContain('--allow-origin') + }) +}) + describe('composeInstallCommand', () => { it('returns the curl | bash one-liner for the bridge installer', () => { const command = composeInstallCommand() diff --git a/src/lib/agent-bridge-command.ts b/src/lib/agent-bridge-command.ts index b8c847e4a..6feebeee2 100644 --- a/src/lib/agent-bridge-command.ts +++ b/src/lib/agent-bridge-command.ts @@ -71,25 +71,40 @@ const needsAllowOrigin = (origin: string | undefined): origin is string => { } /** - * The full bridge command for an agent: - * `zeus bridge --mode acp -- `. `zeus` is the bare binary `install.sh` - * drops on PATH — invoke it directly (no `npx`, which would hit the registry - * since the binary is never published to npm). Returns `null` when the agent - * only ships a binary distribution (no composable launch fragment), so the - * dialog can render its binary fallback instead. + * Build a `zeus bridge --mode -- ` command for an already-resolved + * launch fragment, or `null` when there's nothing to wrap. `zeus` is the bare + * binary `install.sh` drops on PATH — invoked directly (no `npx`, which would hit + * the registry since the binary is never published to npm). * * When `origin` is a non-loopback app origin (production web), the bridge's - * default loopback-only Origin allowlist would reject the browser's WS upgrade, - * so we add `--allow-origin ''`. A loopback origin (or none) needs - * nothing extra — the default allowlist already accepts loopback. - * - * The catalogue is ACP-only, so `--mode acp` is always correct here. + * default loopback-only Origin allowlist would reject the browser's request, so + * we add `--allow-origin ''`. A loopback origin (or none) needs nothing + * extra — the default allowlist already accepts loopback. */ -export const composeBridgeCommand = (entry: RegistryEntry, origin?: string): string | null => { - const launch = composeLaunchCommand(entry) +const composeBridge = (mode: 'acp' | 'mcp', launch: string | null, origin?: string): string | null => { if (!launch) { return null } const allowOrigin = needsAllowOrigin(origin) ? `--allow-origin '${origin}' ` : '' - return `${bridgeBin} bridge --mode acp ${allowOrigin}-- ${launch}` + return `${bridgeBin} bridge --mode ${mode} ${allowOrigin}-- ${launch}` } + +/** + * The full ACP bridge command for a catalogue agent: `zeus bridge --mode acp -- + * `. Returns `null` when the agent only ships a binary distribution (no + * composable launch fragment), so the dialog can render its binary fallback. The + * catalogue is ACP-only, so `--mode acp` is always correct here. + */ +export const composeBridgeCommand = (entry: RegistryEntry, origin?: string): string | null => + composeBridge('acp', composeLaunchCommand(entry), origin) + +/** + * The full bridge command for a local stdio MCP server: `zeus bridge --mode mcp + * -- `. Returns `null` for a blank command. The bridge serves the + * wrapped server over a loopback `http://127.0.0.1:PORT/mcp` endpoint the user + * then adds as a remote MCP server (the loopback carve-out lets the app reach + * it). `origin` adds `--allow-origin` for production web, as in + * `composeBridgeCommand`. + */ +export const composeMcpBridgeCommand = (stdioCommand: string, origin?: string): string | null => + composeBridge('mcp', stdioCommand.trim() || null, origin) diff --git a/src/settings/mcp-servers.tsx b/src/settings/mcp-servers.tsx index d56dd1dd8..55cf63b38 100644 --- a/src/settings/mcp-servers.tsx +++ b/src/settings/mcp-servers.tsx @@ -36,7 +36,7 @@ import { type McpServer } from '@/types' import { useMutation } from '@tanstack/react-query' import { useQuery } from '@powersync/tanstack-react-query' import { eq } from 'drizzle-orm' -import { Check, Copy, Globe, LockKeyhole, Plus, RefreshCw, Server, Trash2, X } from 'lucide-react' +import { Check, Copy, Globe, LockKeyhole, Plus, RefreshCw, Server, Terminal, Trash2, X } from 'lucide-react' import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react' import { useLocation, useNavigate } from 'react-router' import { v7 as uuidv7 } from 'uuid' @@ -58,6 +58,7 @@ import { parseMcpServersConfig, type ParsedMcpServer } from '@/lib/mcp-config-im import { validateMcpServerUrl } from '@/lib/mcp-url-validation' import { useMcpServerOAuth, type McpOAuthCallback, type OAuthCardState } from '@/hooks/use-mcp-server-oauth' import { generateServerName, useAddServerForm } from '@/hooks/use-add-server-form' +import { McpBridgeConnectDialog } from '@/components/settings/mcp-servers/mcp-bridge-connect-dialog' export { generateServerName } @@ -195,6 +196,7 @@ export default function McpServersPage({ deps = {} }: { deps?: McpServersPageDep const location = useLocation() const navigate = useNavigate() const [mode, setMode] = useState('simple') + const [bridgeDialogOpen, setBridgeDialogOpen] = useState(false) const [jsonText, setJsonText] = useState('') const [importError, setImportError] = useState(null) const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(null) @@ -566,212 +568,232 @@ export default function McpServersPage({ deps = {} }: { deps?: McpServersPageDep return (
- { - if (open) { - form.openDialog() - return - } - // Closing (Escape / overlay / X) clears the form and cancels any - // in-flight or pending probe, so nothing lands in the background. - form.resetAddDialog() - resetLocalDialogState() - }} - > - - - - - - Add MCP Server - Add a new MCP server - - - { - if (value !== 'simple' && value !== 'advanced') { - return - } - // Each mode owns a different error source (JSON import vs OAuth - // authorization). Clear both on switch so a stale message from the - // mode you're leaving can't surface under the new mode's UI. - setImportError(null) - clearDialogError() - setMode(value) - }} - className="w-full flex-shrink-0" - > - Simple - Advanced (JSON) - - -
- {mode === 'simple' ? ( -
-
- - form.changeName(e.target.value)} - /> -
+
+ + + + + +

Connect a local server via bridge

+
+
+ { + if (open) { + form.openDialog() + return + } + // Closing (Escape / overlay / X) clears the form and cancels any + // in-flight or pending probe, so nothing lands in the background. + form.resetAddDialog() + resetLocalDialogState() + }} + > + + + + + + Add MCP Server + Add a new MCP server + + + { + if (value !== 'simple' && value !== 'advanced') { + return + } + // Each mode owns a different error source (JSON import vs OAuth + // authorization). Clear both on switch so a stale message from the + // mode you're leaving can't surface under the new mode's UI. + setImportError(null) + clearDialogError() + setMode(value) + }} + className="w-full flex-shrink-0" + > + Simple + Advanced (JSON) + + +
+ {mode === 'simple' ? ( +
+
+ + form.changeName(e.target.value)} + /> +
+ +
+ + form.changeUrl(e.target.value)} + onBlur={handleUrlBlur} + onKeyDown={handleUrlKeyDown} + aria-invalid={urlValidation?.ok === false} + /> + {urlValidation?.ok === false && ( +

{urlValidation.reason}

+ )} +
-
- - form.changeUrl(e.target.value)} - onBlur={handleUrlBlur} - onKeyDown={handleUrlKeyDown} - aria-invalid={urlValidation?.ok === false} - /> - {urlValidation?.ok === false && ( -

{urlValidation.reason}

+
+ + +
+ +
+ + form.changeToken(e.target.value)} + /> +
+ + {newServerUrl && isUrlValid && ( + )} -
-
- - -
+ {testResult.kind === 'success' && ( + } title="Connection successful!"> + {serverCapabilities.length > 0 && ( +
+

Available tools:

+
    + {serverCapabilities.map((capability, index) => ( +
  • +
    + {capability} +
  • + ))} +
+
+ )} +
+ )} -
- - form.changeToken(e.target.value)} - /> + {testResult.kind !== 'success' && + testResult.kind !== 'idle' && + (() => { + const panel = testResultPanels[testResult.kind] + return ( + +

{panel.body}

+
+ ) + })()}
+ ) : ( +
+
+ +