diff --git a/.github/workflows/thunderbolt-release.yml b/.github/workflows/thunderbolt-release.yml new file mode 100644 index 000000000..7fba3b344 --- /dev/null +++ b/.github/workflows/thunderbolt-release.yml @@ -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/. + +# Build, smoke-test, and (on a tag) publish the thunderbolt CLI. +# +# On every push/PR touching cli/** we bundle thunderbolt.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 `thunderbolt-v*` +# tag we additionally attach thunderbolt.cjs to the tag's GitHub Release, which +# install.sh fetches via releases/download/thunderbolt-v/. + +name: thunderbolt release + +on: + push: + branches: [main] + paths: ['cli/**'] + tags: ['thunderbolt-v*'] + pull_request: + paths: ['cli/**'] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +defaults: + run: + working-directory: cli + +jobs: + build: + name: Build & smoke-test thunderbolt.cjs + runs-on: ubuntu-24.04 + # `contents: write` is required by the tag-publish step (gh release create/upload). + # GitHub Actions forbids ${{ }} expressions in `permissions`, so this is a static + # literal. Safe: the publish step is gated by `if: startsWith(github.ref, + # 'refs/tags/thunderbolt-v')`, so non-tag runs never write, and fork-PR tokens are + # read-only regardless of this setting. + permissions: + contents: write + 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 + + # thunderbolt.cjs parses and --help exits 0 (no child, no framing at risk). + - name: Smoke --help + run: | + node dist/thunderbolt.cjs bridge --help | grep -q "thunderbolt" + node dist/thunderbolt.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/thunderbolt.cjs bridge --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/thunderbolt.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 + 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 thunderbolt.cjs (+ sha256) to the tag's GitHub Release + if: startsWith(github.ref, 'refs/tags/thunderbolt-v') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Emit the checksum with a bare `thunderbolt.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 thunderbolt.cjs > thunderbolt.cjs.sha256 ) + if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then + gh release upload "${GITHUB_REF_NAME}" \ + dist/thunderbolt.cjs dist/thunderbolt.cjs.sha256 --clobber + else + gh release create "${GITHUB_REF_NAME}" \ + dist/thunderbolt.cjs dist/thunderbolt.cjs.sha256 \ + --title "${GITHUB_REF_NAME}" --generate-notes + fi 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 diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 000000000..3c25e1e49 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.log diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 000000000..784e86397 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,121 @@ + + +# thunderbolt + +`thunderbolt` 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`). + 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 [options] +thunderbolt 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 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 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 bridge --mode mcp --tunnel -- npx some-mcp-server +``` + +### `thunderbolt bridge` 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. +- **Non-loopback binds always warn loudly.** Any `--host` that isn't loopback + emits a `DANGER` warning on its own — independent of `--allow-any-origin` — + because clients without an `Origin` header (curl, local tools) bypass the + Origin gate, and without `--tunnel` local mode mints no bearer, so a public + bind is reachable from the LAN unauthenticated (with `--tunnel` the mandatory + bearer still gates every request). Adding `--allow-any-origin` stacks its own + warning on top. +- **`--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 + +`thunderbolt` ships as a single self-contained `thunderbolt.cjs` attached to a +GitHub release — there is **no npm publish**. `install.sh` downloads that artifact +and links it onto your `PATH` as `thunderbolt`: + +```sh +curl -fsSL https://raw.githubusercontent.com/thunderbird/thunderbolt/main/cli/install.sh | bash +``` + +The app invokes the published binary as `thunderbolt bridge ...`. + +## Building + +`bun run build` runs `scripts/build-cli.mjs`, which bundles the CLI with esbuild +into `dist/thunderbolt.cjs` (Node 18 target, `bufferutil`/`utf-8-validate` left +external, the version inlined from `package.json`, shebang prepended) and emits a +companion Windows `dist/thunderbolt.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.ts`) drives the real +`@modelcontextprotocol/server-everything` through the official MCP client and +**skips gracefully** when the dependency or network is unavailable. diff --git a/cli/bin/cli.test.ts b/cli/bin/cli.test.ts new file mode 100644 index 000000000..f98a7ae7e --- /dev/null +++ b/cli/bin/cli.test.ts @@ -0,0 +1,508 @@ +// 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 { test, expect, mock } from 'bun:test' +import { run } from './cli' +import { UnavailableError } from '../src/errors' +import type { + BridgeOptions, + GenerateBearer, + MakeLogger, + McpFaceOptions, + StartBridge, + StartMcpFace, + StartTunnel, +} from '../src/types' + +/** A captured process-event listener (mirrors the CLI's injectable `on`). */ +type SignalListener = (...args: unknown[]) => unknown + +/** The injectable collaborators `run` forwards to the bridge subcommand. */ +type HarnessDeps = { + startBridge?: StartBridge + startMcpFace?: StartMcpFace + startTunnel?: StartTunnel + generateBearer?: GenerateBearer + makeLogger?: MakeLogger + on?: (event: string, listener: SignalListener) => void + removeListener?: (event: string, listener: SignalListener) => void +} + +/** A fake stdout/stderr stream the test can read back (`text()`). */ +type Sink = NodeJS.WritableStream & { text: () => string } + +/** Collects writes to a fake stream. */ +const makeSink = (): Sink => { + const chunks: string[] = [] + return { write: (s: string) => chunks.push(s), text: () => chunks.join('') } as unknown as Sink +} + +/** Build a default deps bundle with spies the test can inspect/override. */ +const makeHarness = (over: { deps?: HarnessDeps } = {}) => { + const signals: Record = {} + 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 () => 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(() => {}) + const stdout = makeSink() + const stderr = makeSink() + const deps: HarnessDeps = { + startBridge, + startMcpFace, + startTunnel, + generateBearer, + makeLogger, + on: (sig: string, fn: SignalListener) => { + signals[sig] = fn + }, + removeListener: () => {}, + ...over.deps, + } + return { + face, + mcpFace, + tunnel, + startBridge, + startMcpFace, + startTunnel, + generateBearer, + logger, + makeLogger, + exit, + stdout, + stderr, + signals, + deps, + } +} + +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('thunderbolt ') + 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('thunderbolt ') + 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('thunderbolt bridge --mode ') + 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('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: ['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('bridge --mode acp -- dispatches to startBridge with parsed launch + options', async () => { + const h = makeHarness() + await run({ + argv: ['bridge', '--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 --tunnel: face binds BEFORE the tunnel, which targets the face url with the same minted bearer', async () => { + const h = makeHarness() + const order: string[] = [] + 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: ['bridge', '--mode', 'mcp', '--tunnel', '--', 'srv'], + stdout: h.stdout, + stderr: h.stderr, + exit: h.exit, + deps: h.deps, + }) + 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 () => { + const h = makeHarness() + 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: ['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) +}) + +test('SIGTERM handler closes the face then exits 0', async () => { + const h = makeHarness() + 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) +}) + +test('SIGINT during shutdown: the child dying from the graceful stop must not override exit 130', async () => { + const h = makeHarness() + let captured: BridgeOptions | undefined + // Model the real face: stopping it (face.close) SIGTERMs the child, whose exit fires + // onChildExit with signal 'SIGTERM' (childExitToCode -> 70). That deliberate-stop child + // death must NOT beat the signal handler's intended 130 — exactly one exit, code 130. + const startBridge = mock(async (args: BridgeOptions) => { + captured = args + h.face.close = mock(async () => { + await captured!.onChildExit!({ code: null, signal: 'SIGTERM' }) + }) + return h.face + }) + h.deps.startBridge = startBridge + 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.exit).toHaveBeenCalledTimes(1) + expect(h.exit).toHaveBeenCalledWith(130) +}) + +test('SIGTERM during shutdown: the child dying from the graceful stop must not override exit 0', async () => { + const h = makeHarness() + let captured: BridgeOptions | undefined + const startBridge = mock(async (args: BridgeOptions) => { + captured = args + h.face.close = mock(async () => { + await captured!.onChildExit!({ code: null, signal: 'SIGTERM' }) + }) + return h.face + }) + h.deps.startBridge = startBridge + 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.exit).toHaveBeenCalledTimes(1) + expect(h.exit).toHaveBeenCalledWith(0) +}) + +test('a startup teardown (tunnel failure) keeps its own exit code (69); the reaped child does not override it', async () => { + const h = makeHarness() + let captured: McpFaceOptions | undefined + // The MCP face bound (child live), then the tunnel start throws. The catch reaps the + // face, SIGTERMing the child -> onChildExit(70). The startup error's code (69) must win. + const startMcpFace = mock(async (args: McpFaceOptions) => { + captured = args + h.mcpFace.close = mock(async () => { + await captured!.onChildExit!({ code: null, signal: 'SIGTERM' }) + }) + return h.mcpFace + }) + const startTunnel = mock(async () => { + throw new UnavailableError({ code: 'ECONNREFUSED' }) + }) + h.deps.startMcpFace = startMcpFace + h.deps.startTunnel = startTunnel + await run({ + argv: ['bridge', '--mode', 'mcp', '--tunnel', '--', 'x'], + stdout: h.stdout, + stderr: h.stderr, + exit: h.exit, + deps: h.deps, + }) + expect(h.exit).toHaveBeenCalledTimes(1) + expect(h.exit).toHaveBeenCalledWith(69) +}) + +test('a fatal error during a wedged shutdown still forces SIGKILL + exit 70 (never-orphan backstop stays authoritative)', async () => { + const h = makeHarness() + // The graceful stop wedges: face.close never resolves, so the signal teardown is + // stuck mid-reap with the exit unclaimed-by-completion. A truly uncaught error must + // still force the child down and exit 70 — onFatal does NOT yield to the in-progress + // shutdown (unlike onChildExit, which must). + h.face.close = mock(() => new Promise(() => {})) + const startBridge = mock(async () => h.face) + h.deps.startBridge = startBridge + await run({ + argv: ['bridge', '--mode', 'acp', '--', 'x'], + stdout: h.stdout, + stderr: h.stderr, + exit: h.exit, + deps: h.deps, + }) + void h.signals.SIGINT() // claims `exiting`, then hangs in reap (face.close never resolves) + await Promise.resolve() + h.signals.uncaughtException(new Error('boom')) + expect(h.face.kill).toHaveBeenCalledTimes(1) + expect(h.exit).toHaveBeenCalledWith(70) +}) + +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: ['bridge', '--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: ['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: ['bridge', '--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: ['bridge', '--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 () => { + const h = makeHarness() + let captured: BridgeOptions | undefined + const startBridge = mock(async (args: BridgeOptions) => { + captured = args + return h.face + }) + h.deps.startBridge = startBridge + 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) +}) + +test('a nonzero child exit derives exit 70', async () => { + const h = makeHarness() + let captured: BridgeOptions | undefined + const startBridge = mock(async (args: BridgeOptions) => { + captured = args + return h.face + }) + h.deps.startBridge = startBridge + 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('a natural child exit on a signal (signal:SIGTERM, the bridge got no OS signal) derives exit 70', async () => { + const h = makeHarness() + let captured: BridgeOptions | undefined + const startBridge = mock(async (args: BridgeOptions) => { + captured = args + return h.face + }) + h.deps.startBridge = startBridge + await run({ + argv: ['bridge', '--mode', 'acp', '--', 'x'], + stdout: h.stdout, + stderr: h.stderr, + exit: h.exit, + deps: h.deps, + }) + await captured!.onChildExit!({ code: null, signal: 'SIGTERM' }) + expect(h.exit).toHaveBeenCalledTimes(1) + expect(h.exit).toHaveBeenCalledWith(70) +}) + +test('a natural child exit then a SIGINT exits exactly once with the child-derived code (no double exit)', async () => { + const h = makeHarness() + let captured: BridgeOptions | undefined + const startBridge = mock(async (args: BridgeOptions) => { + captured = args + return h.face + }) + h.deps.startBridge = startBridge + 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 }) + // A signal arriving after the child already claimed the exit must be a no-op. + await h.signals.SIGINT() + expect(h.exit).toHaveBeenCalledTimes(1) + expect(h.exit).toHaveBeenCalledWith(0) +}) + +test('uncaughtException SIGKILLs the live child (face.kill) and exits 70 (never-orphan)', async () => { + const h = makeHarness() + 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) +}) + +test('unhandledRejection SIGKILLs the live child (face.kill) and exits 70 (never-orphan)', async () => { + const h = makeHarness() + 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('bridge --tunnel without --mode mcp is a usage error (exit 64)', async () => { + const h = makeHarness() + 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/cli/bin/cli.ts b/cli/bin/cli.ts new file mode 100644 index 000000000..dafa1acf9 --- /dev/null +++ b/cli/bin/cli.ts @@ -0,0 +1,316 @@ +#!/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, 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. + +import { parseArgs } from '../src/args' +import { EX, toExitCode, toMessage, childExitToCode, UnavailableError } from '../src/errors' +import { makeLogger } from '../src/log' +import { insecureFlagWarnings } from '../src/util' +import { startBridge } from '../src/server' +import { startMcpFace } from '../src/mcp-server' +import { startTunnel, generateBearer } from '../src/tunnel' +import type { + ChildExit, + FaceHandle, + GenerateBearer, + MakeLogger, + ParseArgsResult, + ParsedArgs, + StartBridge, + StartMcpFace, + StartTunnel, + TunnelHandle, +} from '../src/types' + +// esbuild injects this global via `define` at build time; declare it so tsc sees +// it. Undefined when the bin runs un-bundled straight from source. +declare const __BRIDGE_VERSION__: string | undefined + +// 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' + +/** A process-event listener captured for one-shot, never-orphan teardown wiring. */ +type SignalListener = (...args: unknown[]) => unknown + +/** Injectable collaborators forwarded from `run` to the resolved subcommand. */ +type RunDeps = { + startBridge?: StartBridge + startMcpFace?: StartMcpFace + startTunnel?: StartTunnel + generateBearer?: GenerateBearer + makeLogger?: MakeLogger + on?: (event: string, listener: SignalListener) => void + removeListener?: (event: string, listener: SignalListener) => void +} + +/** Options for `run`. Every sink/collaborator is injectable so tests use no real sockets. */ +type RunOptions = { + argv?: string[] + stdout?: NodeJS.WritableStream + stderr?: NodeJS.WritableStream + exit?: (code: number) => void + deps?: RunDeps +} + +/** The io bundle `runBridge` writes diagnostics + exits through. */ +type RunBridgeIo = { + stderr: NodeJS.WritableStream + exit: (code: number) => void + deps: RunDeps +} + +const ROOT_HELP_TEXT = `thunderbolt — Thunderbolt's local stdio bridge toolkit. + +Usage: + thunderbolt [options] + +Commands: + bridge bridge a local stdio ACP/MCP server to a loopback face + +Run \`thunderbolt bridge --help\` for the bridge options. + + -h, --help print this help and exit + -V, --version print the version and exit` + +const BRIDGE_HELP_TEXT = `thunderbolt bridge — bridge a local stdio ACP/MCP server to a loopback face. + +Usage: + thunderbolt 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` + +/** Usage text keyed by the parser's `help` intent (`'root'` | the command name). */ +const HELP: Record<'root' | 'bridge', string> = { root: ROOT_HELP_TEXT, bridge: BRIDGE_HELP_TEXT } + +/** + * PII-safe error code for the uncaught-error backstop log. Mirrors the original + * `err && err.code` truthiness: a truthy `code` is emitted verbatim, otherwise + * the fixed `'INTERNAL'` token (never the message/stack of an arbitrary error). + */ +const fatalCode = (err: unknown): string | number | boolean => { + const code = (err as { code?: string | number | boolean } | null | undefined)?.code + return err && code ? code : 'INTERNAL' +} + +/** + * 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. + */ +const runBridge = async (parsed: ParsedArgs, { stderr, exit, deps }: RunBridgeIo): Promise => { + 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 ?? + ((event: string, listener: SignalListener): void => { + process.on(event, listener) + }) + const offSignal = + deps.removeListener ?? + ((event: string, listener: SignalListener): void => { + process.removeListener(event, listener) + }) + + const logger = _makeLogger({ json: parsed.json, verbose: parsed.verbose, sink: stderr }) + + // 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: FaceHandle | null; tunnel: TunnelHandle | null } = { face: null, tunnel: null } + + const reap = async (): Promise => { + // 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(() => {}) + } + + // Exactly one path decides the exit code. A signal/fatal/startup teardown stops the + // child itself (face.close -> SIGTERM, or face.kill -> SIGKILL), so the child exit it + // triggers must not override the intended code: whichever path runs first claims the + // exit and the others become no-ops. Set synchronously before any `await`, so the + // child's exit (a later event-loop turn) always sees the claim. + let exiting = false + + // The child exiting on its own drives the bridge's own exit code — unless a signal, + // fatal, or startup teardown already claimed it (then the child death is a consequence + // of our deliberate stop and must not override the intended code). + const onChildExit = async (info: ChildExit): Promise => { + if (exiting) return + exiting = true + offSignal('SIGINT', sigintHandler) + offSignal('SIGTERM', sigtermHandler) + if (live.tunnel) await live.tunnel.close().catch(() => {}) + exit(childExitToCode(info)) + } + + // One-shot signal handlers -> graceful stop -> derived exit code. + const handleSignal = (signal: NodeJS.Signals) => async (): Promise => { + if (exiting) return + exiting = true + 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: unknown): void => { + // The never-orphan backstop for truly uncaught errors stays authoritative: it must + // force the child down and exit even if a graceful teardown is already in progress + // (and might be wedged), so it does NOT yield to `exiting`. It still CLAIMS it, so a + // child reaped by its SIGKILL can't override EX.SOFTWARE via onChildExit. + exiting = true + 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: fatalCode(err) }) + 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.ts. + return + } + + // 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, + 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. + // bearer is non-null here: it was minted above under the same `parsed.tunnel`. + live.tunnel = await _startTunnel({ localUrl: face.url, bearer: bearer!, logger }) + } + return + } catch (err) { + if (exiting) return // a signal/child teardown already claimed the exit code + exiting = true + 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)) + } +} + +/** + * 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. + */ +const run = async ({ + argv = process.argv.slice(2), + stdout = process.stdout, + stderr = process.stderr, + exit = process.exit, + deps = {}, +}: RunOptions = {}): Promise => { + const parsed: ParseArgsResult | { error: unknown } = (() => { + try { + return parseArgs(argv) + } catch (err) { + return { error: err } + } + })() + + if ('error' in parsed) { + stderr.write(`${toMessage(parsed.error)}\n`) + return exit(toExitCode(parsed.error)) + } + if ('help' in parsed) { + stdout.write(`${HELP[parsed.help]}\n`) + return exit(EX.OK) + } + if ('version' in parsed) { + 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 `thunderbolt ` is + // a new `case` + a `run` — the bridge path stays untouched. + switch (parsed.command) { + case 'bridge': + return runBridge(parsed, { stderr, exit, deps }) + default: + // Unreachable today (the parser only resolves known commands), but guards a + // future `thunderbolt ` wired into the parser yet not here from silently + // hanging — run() returning without ever calling exit(). + throw new Error(`unhandled command: ${parsed.command}`) + } +} + +export { 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. +// `require`/`module` are CJS globals esbuild preserves in the bundled CJS entry; +// @types/node declares both as ambient globals so the source still type-checks. +if (require.main === module) { + run().catch((err: unknown) => { + process.stderr.write(`${toMessage(err)}\n`) + process.exit(toExitCode(err)) + }) +} diff --git a/cli/bun.lock b/cli/bun.lock new file mode 100644 index 000000000..252009172 --- /dev/null +++ b/cli/bun.lock @@ -0,0 +1,310 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "thunderbolt", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "ws": "^8.18.0", + }, + "devDependencies": { + "@modelcontextprotocol/server-everything": "^2026.1.26", + "@types/bun": "^1.3.14", + "@types/node": "^26.0.1", + "@types/ws": "^8.18.1", + "esbuild": "^0.24.0", + "typescript": "^6.0.3", + }, + "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=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "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=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "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=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "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/cli/install.sh b/cli/install.sh new file mode 100755 index 000000000..dd0531f7e --- /dev/null +++ b/cli/install.sh @@ -0,0 +1,89 @@ +#!/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 installer. +# +# Downloads the prebuilt thunderbolt.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. thunderbolt.cjs already ships a +# `#!/usr/bin/env node` shebang, so dropping it in under the command name makes +# `thunderbolt ` behave like any global node CLI. +# +# curl -fsSL https://raw.githubusercontent.com/thunderbird/thunderbolt/main/cli/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" + +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. +# 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 +# (always current, no GitHub API call so no rate limit). +VERSION="${1:-${THUNDERBOLT_VERSION:-}}" +if [ -z "$VERSION" ]; then + VERSION=$(curl -fsSL "https://raw.githubusercontent.com/$REPO/main/cli/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/thunderbolt-v$VERSION/thunderbolt.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) +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 (` thunderbolt.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 thunderbolt.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 thunderbolt.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. +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/cli/package.json b/cli/package.json new file mode 100644 index 000000000..c37c5c8eb --- /dev/null +++ b/cli/package.json @@ -0,0 +1,41 @@ +{ + "name": "thunderbolt", + "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": "dist/thunderbolt.cjs" + }, + "files": [ + "bin", + "src", + "dist", + "install.sh", + "README.md" + ], + "scripts": { + "build": "node scripts/build-cli.mjs", + "test": "bun test", + "type-check": "tsc --noEmit" + }, + "engines": { + "node": ">=18.17" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@modelcontextprotocol/server-everything": "^2026.1.26", + "@types/bun": "^1.3.14", + "@types/node": "^26.0.1", + "@types/ws": "^8.18.1", + "esbuild": "^0.24.0", + "typescript": "^6.0.3" + }, + "optionalDependencies": { + "bufferutil": "^4.0.8", + "utf-8-validate": "^6.0.4" + } +} diff --git a/cli/scripts/build-cli.mjs b/cli/scripts/build-cli.mjs new file mode 100644 index 000000000..155ee46c5 --- /dev/null +++ b/cli/scripts/build-cli.mjs @@ -0,0 +1,45 @@ +// 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.ts (and its src/ deps) into a single self-contained +// dist/thunderbolt.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', 'thunderbolt.cjs') + +const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) + +await build({ + entryPoints: [join(root, 'bin', 'cli.ts')], + 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) }, + // No shebang banner: esbuild preserves the entry's own `#!/usr/bin/env node` + // (bin/cli.ts 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) + +// Windows ignores the shebang and won't run a bare thunderbolt.cjs from PATH, so +// emit a sibling .cmd shim that forwards every arg to node thunderbolt.cjs. +const cmd = '@echo off\r\nnode "%~dp0thunderbolt.cjs" %*\r\n' +await writeFile(join(root, 'dist', 'thunderbolt.cmd'), cmd) + +console.error(`built ${outfile} (v${pkg.version})`) diff --git a/cli/src/args.test.ts b/cli/src/args.test.ts new file mode 100644 index 000000000..fc53df8d6 --- /dev/null +++ b/cli/src/args.test.ts @@ -0,0 +1,172 @@ +/* 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 { test, expect } from 'bun:test' +import { parseArgs, parseBridgeArgs } from './args' +import { UsageError } from './errors' +import type { BridgeCommand, ParsedArgs } from './types' + +/** + * Parse bridge args, asserting the result is a resolved options object rather + * than a help/version intent (and narrowing the union to `ParsedArgs`). Fails + * loudly if the parser short-circuits, unlike a blanket `as ParsedArgs` that + * would silently mask such a regression. + */ +const parseOk = (argv: string[]): ParsedArgs => { + const parsed = parseBridgeArgs(argv) + if ('help' in parsed || 'version' in parsed) { + throw new Error(`expected resolved bridge opts, got ${JSON.stringify(parsed)}`) + } + return parsed +} + +// --- 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']) as BridgeCommand + 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 = parseOk(['--mode', 'acp', '--', 'node', 'agent.js']) + expect(parsed.mode).toBe('acp') + expect(parsed.launch).toEqual(['node', 'agent.js']) +}) + +test('--help/--version AFTER `--` belong to the child, not thunderbolt', () => { + // The delimiter ends thunderbolt's flags; a `--help` in the launch argv must pass + // through to the child verbatim, not short-circuit to bridge help. parseOk + // asserts a resolved-opts result (never a help/version intent), so it doubles + // as the "not consumed as a thunderbolt flag" check. + const parsed = parseOk(['--mode', 'acp', '--', 'node', 'agent.js', '--help']) + expect(parsed.launch).toEqual(['node', 'agent.js', '--help']) + expect(parseOk(['--mode', 'mcp', '--', 'srv', '--version']).launch).toContain('--version') +}) + +test('--mode mcp --tunnel -- srv → tunnel true', () => { + expect(parseOk(['--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 = parseOk(['--mode', 'acp', '--allow-origin', 'a', '--allow-origin', 'b', '--', 'x']) + expect(parsed.allowOrigins).toEqual(['a', 'b']) +}) + +test('--allow-any-origin sets the flag true', () => { + expect(parseOk(['--mode', 'acp', '--allow-any-origin', '--', 'x']).allowAnyOrigin).toBe(true) +}) + +test('--port 8080 parses to 8080; --port 70000 / --port abc → UsageError', () => { + expect(parseOk(['--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(parseOk(['--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 = parseOk(['--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 = parseOk(['--mode', 'acp', '--json', '--verbose', '--', 'x']) + expect(on.json).toBe(true) + expect(on.verbose).toBe(true) + const off = parseOk(['--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 = parseOk(['--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 = parseOk(['--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/cli/src/args.ts b/cli/src/args.ts new file mode 100644 index 000000000..577ec214a --- /dev/null +++ b/cli/src/args.ts @@ -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/. */ + +import { UsageError } from './errors' +import { resolvePort } from './util' +import type { Mode, ParseArgsResult, ParseBridgeResult, ParsedArgs } from './types' + +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. + */ +const looksLikeFlag = (token: string | undefined): boolean => token !== undefined && token.startsWith('-') + +/** + * 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. + */ +const parseBridgeArgs = (argv: string[]): ParseBridgeResult => { + 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 thunderbolt 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 } + + const opts: Omit & { mode?: Mode } = { + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + tunnel: false, + json: false, + verbose: false, + } + + const takeValue = (flag: string, i: number): string => { + 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 = takeValue(flag, i) as Mode + 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 { ...opts, launch } as ParsedArgs +} + +/** + * 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. + */ +const parseArgs = (argv: string[]): ParseArgsResult => { + 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}`) +} + +export { parseArgs, parseBridgeArgs } diff --git a/cli/src/child.test.ts b/cli/src/child.test.ts new file mode 100644 index 000000000..40a2327ef --- /dev/null +++ b/cli/src/child.test.ts @@ -0,0 +1,256 @@ +// 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 { test, expect, mock, type Mock } from 'bun:test' +import { EventEmitter } from 'node:events' +import { superviseChild } from './child' +import type { Logger, SpawnFn, SuperviseChildOptions } from './types' + +/** Minimal fake ChildProcess: EventEmitter with controllable stdin/stdout. */ +type FakeChild = EventEmitter & { + killed: boolean + pid?: number + lastSignal?: NodeJS.Signals + kill: Mock<(signal?: NodeJS.Signals) => boolean> + stdin: { + destroyed: boolean + writableEnded: boolean + write: Mock<() => boolean> + } + stdout: EventEmitter & { + pause: Mock<() => void> + resume: Mock<() => void> + } +} + +// A successful spawn yields a numeric pid. Tests that need the pidless window +// (a failing spawn that is still `alive` before its async 'error' fires) unset +// `child.pid` explicitly after construction. +const makeFakeChild = (): FakeChild => { + const child = new EventEmitter() as FakeChild + child.killed = false + child.pid = 4242 + child.kill = mock((signal?: NodeJS.Signals) => { + child.killed = true + child.lastSignal = signal + return true + }) + child.stdin = { + destroyed: false, + writableEnded: false, + write: mock(() => true), + } + child.stdout = new EventEmitter() as FakeChild['stdout'] + child.stdout.pause = mock(() => {}) + child.stdout.resume = mock(() => {}) + return child +} + +const noopLogger: Logger = { error: () => {}, warn: () => {}, info: () => {}, banner: () => {} } + +const baseOpts = (child: FakeChild, overrides: Partial = {}): SuperviseChildOptions => ({ + launch: ['node', 'agent.js'], + spawn: mock(() => child) as unknown as SpawnFn, + 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((..._args: unknown[]) => child) + superviseChild(baseOpts(child, { spawn: spawn as unknown as SpawnFn })) + 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((..._args: unknown[]) => {}) + superviseChild(baseOpts(child, { onStdout: onStdout as unknown as SuperviseChildOptions['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((..._args: unknown[]) => {}) + const s = superviseChild(baseOpts(child, { onExit: onExit as unknown as SuperviseChildOptions['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((..._args: unknown[]) => {}) + const onExit = mock((..._args: unknown[]) => {}) + const s = superviseChild( + baseOpts(child, { + onSpawnError: onSpawnError as unknown as SuperviseChildOptions['onSpawnError'], + onExit: onExit as unknown as SuperviseChildOptions['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('a failed spawn that fires error THEN exit tears down exactly once (no double reject)', () => { + const child = makeFakeChild() + const onSpawnError = mock((..._args: unknown[]) => {}) + const onExit = mock((..._args: unknown[]) => {}) + const s = superviseChild( + baseOpts(child, { + onSpawnError: onSpawnError as unknown as SuperviseChildOptions['onSpawnError'], + onExit: onExit as unknown as SuperviseChildOptions['onExit'], + }), + ) + const err = Object.assign(new Error('not found'), { code: 'ENOENT' }) + // Node can emit 'exit' for the same failed spawn after 'error'; only one path may proceed. + child.emit('error', err) + child.emit('exit', null, 'SIGTERM') + expect(onSpawnError).toHaveBeenCalledTimes(1) + expect(onExit).not.toHaveBeenCalled() + expect(s.alive()).toBe(false) +}) + +test('a failed spawn that fires exit THEN error reports exit once and skips spawn-error', () => { + const child = makeFakeChild() + const onSpawnError = mock((..._args: unknown[]) => {}) + const onExit = mock((..._args: unknown[]) => {}) + const s = superviseChild( + baseOpts(child, { + onSpawnError: onSpawnError as unknown as SuperviseChildOptions['onSpawnError'], + onExit: onExit as unknown as SuperviseChildOptions['onExit'], + }), + ) + child.emit('exit', 1, null) + child.emit('error', Object.assign(new Error('not found'), { code: 'ENOENT' })) + expect(onExit).toHaveBeenCalledTimes(1) + expect(onSpawnError).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((..._args: unknown[]) => child) + superviseChild(baseOpts(child, { spawn: spawn as unknown as SpawnFn })) + 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) +}) + +test('kill() on a pidless-but-alive child never signals (no pid-0 group kill)', () => { + // A failing spawn is `alive` until its async 'error' fires; in that window + // pid is undefined and child.kill('SIGKILL') would hit pid 0 (our own group). + const child = makeFakeChild() + child.pid = undefined + const s = superviseChild(baseOpts(child)) + expect(s.alive()).toBe(true) + s.kill() + expect(child.kill).not.toHaveBeenCalled() +}) + +test('stop() on a pidless-but-alive child never signals and arms no grace timer', async () => { + const child = makeFakeChild() + child.pid = undefined + const s = superviseChild(baseOpts(child, { graceMs: 10 })) + expect(s.alive()).toBe(true) + s.stop() + // No initial signal, and no grace-timer SIGKILL once the window elapses. + await new Promise((r) => setTimeout(r, 25)) + expect(child.kill).not.toHaveBeenCalled() +}) + +test('grace-timer SIGKILL is skipped if the child became pidless after stop()', async () => { + // stop() fires on a real pid (initial SIGTERM lands), but if the child loses + // its pid before the grace window elapses the forced SIGKILL must not hit pid 0. + const child = makeFakeChild() + const s = superviseChild(baseOpts(child, { graceMs: 10 })) + s.stop() + expect(child.kill).toHaveBeenLastCalledWith('SIGTERM') + child.pid = undefined + await new Promise((r) => setTimeout(r, 25)) + const sigkills = child.kill.mock.calls.filter((c) => c[0] === 'SIGKILL') + expect(sigkills.length).toBe(0) +}) diff --git a/cli/src/child.ts b/cli/src/child.ts new file mode 100644 index 000000000..1915cb893 --- /dev/null +++ b/cli/src/child.ts @@ -0,0 +1,111 @@ +// 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. + +import { spawn as defaultSpawn } from 'node:child_process' +import type { SuperviseChild } from './types' + +/** 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. 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 superviseChild: SuperviseChild = ({ + launch, + spawn = defaultSpawn, + onStdout, + onExit, + onSpawnError, + logger, + graceMs = GRACE_MS, +}) => { + const child = spawn(launch[0], launch.slice(1), { stdio: ['pipe', 'pipe', 'inherit'] }) + + const state = { alive: true, exited: false } + let graceTimer: NodeJS.Timeout | null = null + + const clearGrace = (): void => { + if (graceTimer) { + clearTimeout(graceTimer) + graceTimer = null + } + } + + child.on('error', (err) => { + if (state.exited) return + state.exited = true + 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') { + // A pidless child means the spawn never produced an OS process (the + // error event just hasn't flipped `alive` yet) — there's nothing to + // signal, and `child.kill()` on pid 0 would broadcast to our own process + // group. Skip it: no live process means nothing to orphan. + if (!state.alive || child.pid == null) return + child.kill(signal) + clearGrace() + graceTimer = setTimeout(() => { + // Never-orphan: if the child ignored the signal, force it down. + if (state.alive && child.pid != null) { + 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() + // Guard on pid too: SIGKILL on a pidless child (pid 0) is a process-group + // kill — it would take the bridge itself down. Spawn failed, so there's + // no OS process to orphan. + if (state.alive && child.pid != null) child.kill('SIGKILL') + }, + + alive() { + return state.alive + }, + } +} + +export { superviseChild } diff --git a/cli/src/errors.test.ts b/cli/src/errors.test.ts new file mode 100644 index 000000000..779076c5c --- /dev/null +++ b/cli/src/errors.test.ts @@ -0,0 +1,81 @@ +/* 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 { test, expect } from 'bun:test' +import { EX, UsageError, UnavailableError, SigintError, toExitCode, toMessage, childExitToCode } from './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/cli/src/errors.ts b/cli/src/errors.ts new file mode 100644 index 000000000..79a836fb6 --- /dev/null +++ b/cli/src/errors.ts @@ -0,0 +1,110 @@ +/* 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 type { ChildExit, ExitCode, UnavailableErrorOptions } from './types' + +/** + * Canonical sysexits exit-code table for the bridge. + */ +const EX = Object.freeze({ + OK: 0, + USAGE: 64, + UNAVAILABLE: 69, + SOFTWARE: 70, + SIGINT: 130, +} as const) + +/** + * Node error codes that classify as EX_UNAVAILABLE (69) — the single source of + * truth for the unavailable classification. + */ +const UNAVAILABLE_CODES = new Set(['ENOENT', 'EADDRINUSE', 'EACCES', 'EADDRNOTAVAIL', 'ECONNREFUSED']) + +/** Fixed, PII-safe phrases for each unavailable Node error code. */ +const UNAVAILABLE_PHRASES: Record = 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 { + constructor(message: string) { + super(message) + this.name = 'UsageError' + } +} + +/** A resource-unavailable error → exit 69. Carries a Node errorCode string. */ +class UnavailableError extends Error { + code?: string + constructor(opts: UnavailableErrorOptions = {}) { + super(opts.message ?? opts.code ?? 'unavailable') + this.name = 'UnavailableError' + 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. + */ +const codeOf = (err: unknown): string | undefined => + typeof err === 'object' && err !== null && typeof (err as { code?: unknown }).code === 'string' + ? (err as { code: string }).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. + */ +const toExitCode = (err: unknown): ExitCode => { + 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. + */ +const toMessage = (err: unknown): string => { + 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. + */ +const childExitToCode = (exit: ChildExit): ExitCode => { + if (exit.signal === 'SIGINT') return EX.SIGINT + if (exit.signal) return EX.SOFTWARE + if (exit.code === 0) return EX.OK + return EX.SOFTWARE +} + +export { EX, UsageError, UnavailableError, SigintError, toExitCode, toMessage, childExitToCode } diff --git a/cli/src/log.test.ts b/cli/src/log.test.ts new file mode 100644 index 000000000..03a2de44e --- /dev/null +++ b/cli/src/log.test.ts @@ -0,0 +1,146 @@ +/* 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 { test, expect } from 'bun:test' +import { makeLogger, buildOriginAllowlist, classifyMethod, classifyId, classifyFrame, safeClassifyFrame } from './log' +import type { LogFields } from './types' + +/** A fake writable sink that records every written line. */ +type RecordingSink = NodeJS.WritableStream & { lines: string[] } + +const makeSink = (): RecordingSink => { + const lines: string[] = [] + return { + lines, + write(chunk: string) { + lines.push(chunk) + return true + }, + } as unknown as RecordingSink +} + +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', + } as unknown as LogFields) + 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('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 }) + 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/cli/src/log.ts b/cli/src/log.ts new file mode 100644 index 000000000..d833e7582 --- /dev/null +++ b/cli/src/log.ts @@ -0,0 +1,167 @@ +/* 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 { isLoopbackHost } from './util' +import type { + ClassifiedFrame, + FrameIdKind, + LogFields, + Logger, + LoggerOptions, + OriginAllowlist, + OriginAllowlistOptions, +} from './types' + +/** + * Field keys that may be logged. Everything else is dropped so no payload data + * leaks into a log line. + */ +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). + */ +const filterScalars = (fields?: LogFields): Record => { + const out: Record = {} + if (!fields) return out + for (const key of Object.keys(fields)) { + if (!ALLOWED_FIELDS.has(key)) continue + const value = fields[key] + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') out[key] = value + } + return out +} + +/** Render a filtered field set as a compact `key=value` suffix. */ +const renderFields = (fields: Record): string => + 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. + */ +const makeLogger = ({ json, verbose, sink }: LoggerOptions): Logger => { + const write = (level: string, event: string, fields?: LogFields): void => { + 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 { + info(event: string, fields?: LogFields): void { + if (!verbose) return + write('info', event, fields) + }, + warn(event: string, fields?: LogFields): void { + write('warn', event, fields) + }, + error(event: string, fields?: LogFields): void { + 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. + */ + banner(url: string): void { + 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. + */ +const normalizeOrigin = (origin: string): string | null => { + try { + const parsed = new URL(origin) + return parsed.port + ? `${parsed.protocol}//${parsed.hostname}:${parsed.port}` + : `${parsed.protocol}//${parsed.hostname}` + } catch { + return null + } +} + +/** + * 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). + */ +const buildOriginAllowlist = ({ allowOrigins, allowAnyOrigin }: OriginAllowlistOptions): OriginAllowlist => { + 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. + */ +const classifyMethod = (frame: unknown): string => { + if (typeof frame === 'object' && frame !== null) { + const method = (frame as { method?: unknown }).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'. + */ +const classifyId = (frame: unknown): FrameIdKind => { + if (typeof frame !== 'object' || frame === null) return 'absent' + const obj = frame as { id?: unknown; method?: unknown } + 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' +} + +/** + * 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. + */ +const classifyFrame = (frame: unknown): ClassifiedFrame => ({ 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. + */ +const safeClassifyFrame = (raw: string): ClassifiedFrame => { + try { + return classifyFrame(JSON.parse(raw)) + } catch { + return classifyFrame(null) + } +} + +export { makeLogger, buildOriginAllowlist, classifyMethod, classifyId, classifyFrame, safeClassifyFrame } diff --git a/cli/src/mcp-multiplexer.test.ts b/cli/src/mcp-multiplexer.test.ts new file mode 100644 index 000000000..be7a9ebc9 --- /dev/null +++ b/cli/src/mcp-multiplexer.test.ts @@ -0,0 +1,211 @@ +/* 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 { test, expect, mock, type Mock } from 'bun:test' +import { createMultiplexer } from './mcp-multiplexer' +import type { JsonRpcId, JsonRpcMessage, McpTransport, McpTransportClass, Multiplexer } from './types' + +/** A silent PII-safe logger spy. */ +const makeLogger = () => ({ + info: mock(() => {}), + warn: mock(() => {}), + error: mock(() => {}), + banner: mock(() => {}), +}) + +/** The test's view of a per-request transport once the mux has wired its onmessage. */ +type FakeTransport = { + onmessage: (message: JsonRpcMessage) => void + send: Mock<(message: JsonRpcMessage) => Promise> + close: Mock<() => Promise> +} + +/** A fake per-request transport: captures sent frames; onmessage is wired by the mux. */ +const makeTransport = () => ({ + onmessage: null as ((message: JsonRpcMessage) => void) | null, + send: mock((_message: JsonRpcMessage) => Promise.resolve()), + close: mock(() => Promise.resolve()), +}) + +/** A fake StreamableHTTPServerTransport class the mux instantiates per request. */ +const TransportClass = class { + constructor() { + return makeTransport() + } +} as unknown as McpTransportClass + +/** Create a per-request transport and view it as the FakeTransport the test drives. */ +const create = (mux: Multiplexer): FakeTransport => mux.createTransport(TransportClass) as unknown as FakeTransport + +/** Build a mux whose child writes are captured as parsed frames. */ +const makeMux = () => { + const logger = makeLogger() + const written: JsonRpcMessage[] = [] + const mux = createMultiplexer({ + writeChild: (frame: string) => written.push(JSON.parse(frame.replace(/\n$/, ''))), + logger, + }) + return { mux, written, logger } +} + +/** A standard client initialize request. */ +const initRequest = (id: JsonRpcId): JsonRpcMessage => ({ + 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 = create(mux) + 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 = create(mux) + 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 = create(mux) + 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 = create(mux) + 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 = create(mux) + const t2 = create(mux) + // 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 = create(mux) + const t2 = create(mux) + 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 = create(mux) + const t2 = create(mux) + // 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 = create(mux) + const t2 = create(mux) + 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 = create(mux) + const t2 = create(mux) + t1.onmessage({ jsonrpc: '2.0', id: 5, method: 'tools/list', params: {} }) + const g1 = written[0].id + mux.releaseTransport(t1 as unknown as McpTransport) + // 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 = create(mux) + 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 = create(mux) + 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 = create(mux) + const t2 = create(mux) + 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/cli/src/mcp-multiplexer.ts b/cli/src/mcp-multiplexer.ts new file mode 100644 index 000000000..2f7808187 --- /dev/null +++ b/cli/src/mcp-multiplexer.ts @@ -0,0 +1,201 @@ +// 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. + +import { classifyFrame } from './log' +import { wsToFrame } from './relay' +import type { JsonRpcId, JsonRpcMessage, McpTransport, Multiplexer, MultiplexerOptions } from './types' + +/** A client id paired with the transport that owns it (pending route / init waiter). */ +type Route = { transport: McpTransport; clientId: JsonRpcId | null | undefined } + +/** Build the multiplexer. */ +const createMultiplexer = ({ writeChild, logger }: MultiplexerOptions): Multiplexer => { + /** 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. + */ + 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 = (): string => `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. + */ + let childInitResult: unknown = 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: string | null = 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. + */ + const initWaiters: Route[] = [] + + /** Send one frame to a transport, swallowing a benign disconnected-client reject. */ + const sendTo = (transport: McpTransport, message: JsonRpcMessage): void => { + 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: JsonRpcMessage): void => { + 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: McpTransport, clientId: JsonRpcId | null | undefined): void => { + 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: McpTransport, message: JsonRpcMessage): void => { + 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 true when the message was the awaited initialize + * reply. + */ + const captureInitReply = (message: JsonRpcMessage): boolean => { + 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() + }, + } +} + +export { createMultiplexer } diff --git a/cli/src/mcp-server.integration.test.ts b/cli/src/mcp-server.integration.test.ts new file mode 100644 index 000000000..7ad14c4a7 --- /dev/null +++ b/cli/src/mcp-server.integration.test.ts @@ -0,0 +1,146 @@ +/* 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/. */ + +// 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. + +import { test, describe, expect, beforeAll, afterAll } from 'bun:test' +import * as path from 'node:path' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { makeLogger } from './log' +import { startMcpFace } from './mcp-server' +import type { FaceHandle } from './types' + +/** The subset of a server-everything package.json this resolver reads. */ +type ServerEverythingPkg = { bin?: string | Record; main?: string } + +/** + * 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 = (): string[] | null => { + try { + const pkgPath = require.resolve('@modelcontextprotocol/server-everything/package.json') + const pkg = require(pkgPath) as ServerEverythingPkg + 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)', () => { + let face: FaceHandle | null = null + let client: Client | null = null + + beforeAll(async () => { + const logger = makeLogger({ json: false, verbose: false, sink: process.stderr }) + face = await startMcpFace({ + launch: 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 ?? []) as Array<{ text?: string }>).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: string) => { + 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 ?? []) as Array<{ text?: string }>).map((c) => c.text ?? '').join('')).toContain('from-first') + expect(((e2.content ?? []) as Array<{ text?: string }>).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 ?? []) as Array<{ text?: string }>).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', () => { + // 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/cli/src/mcp-server.test.ts b/cli/src/mcp-server.test.ts new file mode 100644 index 000000000..5071ecba1 --- /dev/null +++ b/cli/src/mcp-server.test.ts @@ -0,0 +1,713 @@ +/* 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 { test, expect, mock, type Mock } from 'bun:test' +import { EventEmitter } from 'node:events' +import { createHash } from 'node:crypto' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { startMcpFace, bearerMatches } from './mcp-server' +import type { ChildExit, JsonRpcMessage, Logger, McpFaceDeps, McpFaceOptions, SuperviseChildOptions } from './types' + +/** Fake http.Server shape: EventEmitter with listen/address/close. */ +type FakeServer = EventEmitter & { + listening: boolean + _port?: number + listen: Mock<(port: number, host: string, cb: () => void) => unknown> + address(): { port: number | undefined } + close: Mock<(cb?: () => void) => unknown> + closeAllConnections: Mock<() => void> +} + +/** Fake StreamableHTTPServerTransport shape (one per HTTP request under the mux). */ +type MockTransport = { + onmessage: ((message: JsonRpcMessage) => void) | null + send: Mock<(message: JsonRpcMessage) => Promise> + handleRequest: Mock<(req: IncomingMessage, res: ServerResponse, body?: unknown) => Promise> + close: Mock<() => Promise> +} + +/** Fake request shape: an EventEmitter with method/url/headers + replayable body. */ +type FakeReq = EventEmitter & { + method: string + url: string + headers: Record + destroy: Mock<() => void> + resume: Mock<() => void> + _body?: string +} + +/** Captured supervise hooks the test drives. */ +type Hooks = { + onStdout?: (chunk: Buffer) => void + onExit?: (info: ChildExit) => void + onSpawnError?: (err: NodeJS.ErrnoException) => void +} + +/** SHA-256 digest a string to a 32-byte buffer, matching the expected-bearer digest. */ +const digest = (value: string): Buffer => createHash('sha256').update(value).digest() + +/** A silent PII-safe logger spy. */ +const makeLogger = () => ({ + info: mock((_event: string) => {}), + warn: mock((_event: string) => {}), + error: mock((_event: string) => {}), + banner: mock((_url: string) => {}), +}) + +/** Fake http.Server: EventEmitter with listen/address/close. */ +const makeFakeServer = (): FakeServer => { + const server = new EventEmitter() as FakeServer + server.listening = false + server.listen = mock((port: number, _host: string, cb: () => void) => { + server._port = port === 0 ? 54321 : port + server.listening = true + queueMicrotask(cb) + return server + }) + server.address = () => ({ port: server._port }) + server.close = mock((cb?: () => void) => { + server.listening = false + if (cb) queueMicrotask(cb) + return server + }) + server.closeAllConnections = mock(() => {}) + return server +} + +/** Fake StreamableHTTPServerTransport (one per HTTP request under the multiplexer). */ +const makeFakeTransport = (): MockTransport => { + const transport: MockTransport = { + onmessage: null, + send: mock((_message: JsonRpcMessage) => Promise.resolve()), + handleRequest: mock((_req: IncomingMessage, _res: ServerResponse, _body?: unknown) => Promise.resolve()), + close: mock(() => Promise.resolve()), + } + return transport +} + +/** Fake superviseChild controller capturing wiring + calls. */ +const makeFakeSupervisor = () => { + const calls = { stdin: [] as string[], paused: 0, resumed: 0, stopped: 0, killed: 0 } + const supervisor = { + child: { stdin: new EventEmitter() }, + writeStdin: mock((chunk: string | Buffer) => { + 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: Record = {}) => { + const server = makeFakeServer() + const { supervisor, calls } = makeFakeSupervisor() + const hooks: 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: MockTransport[] = [] + // 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: FakeTransport, + superviseChild: mock((opts: SuperviseChildOptions) => { + hooks.onStdout = opts.onStdout + hooks.onExit = opts.onExit + hooks.onSpawnError = opts.onSpawnError + return supervisor + }), + ...overrides, + } + return { server, transport, transports, hold, supervisor, calls, hooks, deps: deps as unknown as McpFaceDeps } +} + +const baseOpts = (logger: Logger, deps: McpFaceDeps, extra: Partial = {}): McpFaceOptions => ({ + 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, +}: { method?: string; url?: string; headers?: Record; body?: string } = {}): FakeReq => { + const req = new EventEmitter() as FakeReq + 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 as number | null, + headers: {} as Record, + ended: false, + writeHead: mock((status: number) => { + res.statusCode = status + }), + setHeader: mock((k: string, v: unknown) => { + 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: FakeServer, req: FakeReq, res: ReturnType): Promise => { + 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', 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('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() + 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') + // 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 () => { + 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: unknown[] = [] + const onRejection = (err: unknown) => 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('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('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() + 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('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: unknown[] = [] + const onRejection = (err: unknown) => 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 a client request to child stdin as one NDJSON line (id remapped)', async () => { + const logger = makeLogger() + 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) + // 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('a child response routes back to the owning transport with the original client id', async () => { + const logger = makeLogger() + const { server, transport, calls, hooks, deps } = makeHarness() + await startMcpFace(baseOpts(logger, deps)) + // 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: { 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 { server, transport, calls, hooks, deps } = makeHarness() + const rejections: unknown[] = [] + const onRejection = (err: unknown) => rejections.push(err) + process.on('unhandledRejection', onRejection) + await startMcpFace(baseOpts(logger, deps)) + // 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) + 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() + 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 + every live transport and resolves close()', async () => { + const logger = makeLogger() + 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() +}) + +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 every live transport, stops the child, and closes the server: no orphan', async () => { + const logger = makeLogger() + 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) + 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() + // 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 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)) + // 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() + // The child never spawned, so onSpawnError must not attempt a kill. + expect(calls.killed).toBe(0) +}) + +test('a spawn error that lands AFTER the face resolved (listen won the race) reaps the child instead of leaving a zombie face', async () => { + const logger = makeLogger() + const { server, hooks, calls, deps } = makeHarness() + const onChildExit = mock(() => {}) + // listen wins the race: the start promise resolves a live face first... + const face = await startMcpFace(baseOpts(logger, deps, { onChildExit })) + expect(typeof face.url).toBe('string') + // ...then the child's spawn error lands late. The resolved face must NOT be left + // pointing at a dead child: the http server is torn down and the caller is told + // via onChildExit, exactly as a self-exit would. Without the once-guard the late + // onSpawnError would server.close() under the live face and skip onChildExit. + hooks.onSpawnError!(Object.assign(new Error('enoent'), { code: 'ENOENT' })) + expect(server.close).toHaveBeenCalled() + expect(onChildExit).toHaveBeenCalledTimes(1) + expect(onChildExit).toHaveBeenCalledWith({ code: null, signal: null }) + // Reaping a failed spawn must not SIGKILL (there is no live child to kill). + expect(calls.killed).toBe(0) +}) + +test('a server error AFTER the face resolved (post-bind) is a no-op: the live child is not killed and the face is not torn down', async () => { + const logger = makeLogger() + const { server, calls, deps } = makeHarness() + const onChildExit = mock(() => {}) + // listen wins the race: the start promise resolves a live face (startSettled=true)... + const face = await startMcpFace(baseOpts(logger, deps, { onChildExit })) + expect(typeof face.url).toBe('string') + // ...then the http server emits a late 'error'. The pre-listen handler SIGKILLs the + // child and rejects; once start has settled that path must be a no-op so the resolved + // face keeps its live child intact (mirrors the ACP face's guarded wss 'error'). Without + // the startSettled guard this would SIGKILL the child under an already-resolved face. + server.emit('error', Object.assign(new Error('late'), { code: 'ECONNRESET' })) + expect(calls.killed).toBe(0) + expect(server.close).not.toHaveBeenCalled() + expect(onChildExit).not.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() as FakeServer + 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/cli/src/mcp-server.ts b/cli/src/mcp-server.ts new file mode 100644 index 000000000..2051fb40e --- /dev/null +++ b/cli/src/mcp-server.ts @@ -0,0 +1,381 @@ +// 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 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. + +import { createServer as defaultCreateServer } from 'node:http' +import type { IncomingMessage, ServerResponse } from 'node:http' +import type { AddressInfo } from 'node:net' +import { createHash, timingSafeEqual } from 'node:crypto' +import { StreamableHTTPServerTransport as DefaultStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' +import { UnavailableError } from './errors' +import { buildOriginAllowlist, safeClassifyFrame } from './log' +import { createNdjsonReader } from './relay' +import { createMultiplexer } from './mcp-multiplexer' +import { superviseChild as defaultSuperviseChild } from './child' +import { formatHostForUrl, makeCloseLatch } from './util' +import type { ChildExit, JsonRpcMessage, McpTransport, McpTransportClass, StartMcpFace } from './types' + +/** 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']) + +/** SHA-256 digest a string to a fixed 32-byte buffer. */ +const sha256 = (value: string): Buffer => createHash('sha256').update(value).digest() + +/** + * 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. + */ +const bearerMatches = (provided: string | undefined, expectedDigest: Buffer): boolean => { + if (typeof provided !== 'string') return false + return timingSafeEqual(sha256(provided), expectedDigest) +} + +/** Extract the `Bearer ` value from an Authorization header, or undefined. */ +const readBearer = (req: IncomingMessage): string | undefined => { + 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: ServerResponse, status: number): void => { + 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. + */ +const parseBody = (body: string): unknown => { + 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). + */ +const readBody = (req: IncomingMessage, cap: number): Promise => + new Promise((resolve) => { + const chunks: Buffer[] = [] + let size = 0 + let overflowed = false + let settled = false + const settle = (value: string | typeof BODY_TOO_LARGE | typeof BODY_ABORTED): void => { + if (settled) return + settled = true + resolve(value) + } + req.on('data', (chunk: Buffer) => { + 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, + * and multiplex many HTTP MCP clients onto the single child's NDJSON stdio. + * Bearer-before-route, CORS, body cap, deterministic teardown, never-orphan. + */ +const startMcpFace: StartMcpFace = ({ + launch, + host, + port, + bearer, + allowOrigins, + allowAnyOrigin, + bodyCapBytes = DEFAULT_BODY_CAP_BYTES, + logger, + onChildExit, + deps = {}, +}) => { + const createServer = deps.createServer ?? defaultCreateServer + // Adapt the SDK transport to the bridge's structural McpTransportClass. A single + // `as` (not `as unknown as`) keeps the conversion checked: it requires the SDK + // class to still structurally overlap the stateless-constructor + handleRequest/ + // send/close/onmessage shape the mux drives, so constructor or method drift in a + // future SDK surfaces here at compile time instead of silently at runtime. + const StreamableHTTPServerTransport = + deps.StreamableHTTPServerTransport ?? (DefaultStreamableHTTPServerTransport as McpTransportClass) + 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 latch = makeCloseLatch() + + // The start promise settles exactly once. `listen` success resolves a live + // face; a spawn/bind error rejects. This once-guard makes whichever fires + // first win so the loser is a no-op — `latch.settled()` only tracks the CLOSE + // lifecycle, so without it a spawn error that lands just AFTER the listen + // callback resolved would tear the http server down under an already-resolved + // face (and silently skip onChildExit), leaving the caller holding a live + // handle pointing at a dead child. See reapChild + onSpawnError below. + let startSettled = false + + // 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 { + return JSON.parse(line) as JsonRpcMessage + } catch { + logger.warn('drop-child-frame', safeClassifyFrame(line)) + return null + } + })() + if (message) mux.onChildMessage(message) + }) + + const server = createServer() + + const finishClose = latch.finishClose + + // 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 = () => { + mux.closeAll() + server.close(finishClose) + if (typeof server.closeAllConnections === 'function') server.closeAllConnections() + } + + // Flush the child's stdout, tear the http server down, and notify the caller. + // Shared by a normal self-exit and by a spawn error that lands AFTER the face + // already resolved, so a resolved face never silently points at a dead child. + const reapChild = (info: ChildExit): void => { + reader.flush() + shutdownHttp() + if (onChildExit) onChildExit(info) + } + + const supervisor = superviseChild({ + launch, + spawn: deps.spawn, + logger, + onStdout: (chunk) => reader.push(chunk), + onExit: reapChild, + onSpawnError: (err) => { + // A spawn failure (ENOENT/EACCES): the child never produced a usable face. + // If listen already won the race and resolved the start promise, reap like + // a self-exit so the caller learns the child is dead and nothing is + // orphaned; otherwise this is the first settle, so reject. + if (startSettled) { + reapChild({ code: null, signal: null }) + return + } + startSettled = true + server.close() + reject(new UnavailableError({ code: err.code })) + }, + }) + + const applyCors = (req: IncomingMessage, res: ServerResponse): void => { + const origin = req.headers.origin + if (allowAnyOrigin) { + res.setHeader('Access-Control-Allow-Origin', '*') + } else if (typeof origin === 'string' && isOriginAllowed(origin)) { + // Reflect the request Origin ONLY after the isOriginAllowed() allowlist gate + // (loopback + explicit --allow-origin); an arbitrary origin gets no ACAO header — + // the correct credentialed-CORS pattern. Bare trailing nosemgrep on the matched line + // (the rule-id form did not suppress the registry cors-misconfiguration rule). + res.setHeader('Access-Control-Allow-Origin', origin) // nosemgrep + res.setHeader('Vary', 'Origin') + } + 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-Expose-Headers', 'Mcp-Session-Id') + } + + // 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: ServerResponse, makeHandle: (transport: McpTransport) => Promise): void => { + const transport = mux.createTransport(StreamableHTTPServerTransport) + Promise.resolve(makeHandle(transport)) + .catch((err: NodeJS.ErrnoException) => { + 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) => { + // 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) + 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 + } + dispatch(res, (transport) => transport.handleRequest(req, res, parsed)) + return + } + + dispatch(res, (transport) => transport.handleRequest(req, res)) + }) + + server.on('error', (err: NodeJS.ErrnoException) => { + // Bind failures (EADDRINUSE/EACCES) arrive here before 'listening'. If a spawn + // error already won the start race this is a no-op (never double-kill/reject); + // a failed bind never emits 'listening', so the two stay mutually exclusive. + if (startSettled) return + supervisor.kill() // never-orphan + server.close() + reject(new UnavailableError({ code: err.code })) + }) + + server.listen(port, host, () => { + // If a spawn/bind error already rejected (won the race), the http server is + // being torn down — resolving now would hand back a dead face (and print a + // spurious banner), so this callback is a no-op once the start has settled. + if (startSettled) return + startSettled = true + const actualPort = (server.address() as AddressInfo).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) => { + latch.setResolver(resolveOuter) + supervisor.stop() // grace -> SIGKILL, never-orphan + shutdownHttp() + }), + }) + }) + }) +} + +export { startMcpFace, bearerMatches } diff --git a/cli/src/relay.test.ts b/cli/src/relay.test.ts new file mode 100644 index 000000000..88155d9b0 --- /dev/null +++ b/cli/src/relay.test.ts @@ -0,0 +1,86 @@ +/* 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 { test, expect } from 'bun:test' +import { createNdjsonReader, frameToWs, wsToFrame, MalformedFrameError, parseFrame } from './relay' + +const collect = () => { + const lines: string[] = [] + 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/cli/src/relay.ts b/cli/src/relay.ts new file mode 100644 index 000000000..6b5806bd0 --- /dev/null +++ b/cli/src/relay.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 type { JsonRpcMessage, LineHandler, NdjsonReader } from './types' + +/** 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. + */ +const createNdjsonReader = (onLine: LineHandler): NdjsonReader => { + let buffer = '' + + const emit = (raw: string): void => { + const line = raw.replace(/\r$/, '').trim() + if (line.length > 0) onLine(line) + } + + return { + push(chunk: Buffer | string): void { + 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(): void { + 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. + */ +const parseFrame = (text: string): JsonRpcMessage => { + try { + return JSON.parse(text) as JsonRpcMessage + } 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. + */ +const frameToWs = (line: string): string => { + 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. + */ +const wsToFrame = (message: string): string => { + parseFrame(message) + return `${message}\n` +} + +export { MalformedFrameError, createNdjsonReader, frameToWs, wsToFrame, parseFrame } diff --git a/cli/src/server.test.ts b/cli/src/server.test.ts new file mode 100644 index 000000000..9aba3e077 --- /dev/null +++ b/cli/src/server.test.ts @@ -0,0 +1,636 @@ +// 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 { test, expect, mock, type Mock } from 'bun:test' +import { EventEmitter } from 'node:events' +import { startBridge, HIGH_WATER, LOW_WATER, CLOSE_NORMAL } from './server' +import type { BridgeDeps, BridgeOptions, ChildExit, SuperviseChildOptions } from './types' + +const OPEN = 1 + +/** The options object the WebSocketServer constructor captures. */ +type WssOpts = { + host: string + port: number + verifyClient: (info: { origin: string | undefined }) => boolean +} + +/** The structural shape a captured fake WebSocketServer instance exposes. */ +type FakeWssInstance = EventEmitter & { + _port: number + closed?: boolean + address(): { port: number } + close(cb?: () => void): void +} + +/** Fake WS client socket shape. */ +type FakeWs = EventEmitter & { + OPEN: number + readyState: number + bufferedAmount: number + lastCloseCode?: number + send: Mock<(data: unknown, cb?: () => void) => void> + close: Mock<(code?: number) => void> + pause: Mock<() => void> + resume: Mock<() => void> +} + +type FakeStdin = EventEmitter & { destroyed: boolean } + +/** Fake supervisor shape captured so the test can drive child stdout/exit. */ +type FakeSupervisor = { + child: { stdin: FakeStdin } + onStdout: ((chunk: Buffer) => void) | null + onExit: ((info: ChildExit) => void) | null + onSpawnError: ((err: NodeJS.ErrnoException) => void) | null + launch?: string[] + writeStdin: Mock<(...args: unknown[]) => boolean> + pauseStdout: Mock<() => void> + resumeStdout: Mock<() => void> + stop: Mock<() => void> + kill: Mock<() => void> + alive: () => boolean +} + +/** Fake WebSocketServer: EventEmitter capturing its options, with address/close. */ +const makeFakeWss = () => { + let opts: WssOpts | null = null + class FakeWss extends EventEmitter { + _port: number + closed?: boolean + constructor(o: WssOpts) { + super() + opts = o + this._port = 5123 + } + address() { + return { port: this._port } + } + close(cb?: () => void) { + this.closed = true + if (cb) cb() + } + } + return { FakeWss, getOpts: () => opts } +} + +/** Fake WS client socket. */ +const makeFakeWs = (overrides: Partial = {}): FakeWs => { + const ws = new EventEmitter() as FakeWs + ws.OPEN = OPEN + ws.readyState = OPEN + ws.bufferedAmount = 0 + ws.send = mock((_data: unknown, cb?: () => void) => { + if (cb) cb() + }) + ws.close = mock((code?: number) => { + 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() as FakeStdin + stdin.destroyed = false + const sup: FakeSupervisor = { + child: { stdin }, + onStdout: null, + onExit: null, + onSpawnError: null, + writeStdin: mock((..._args: unknown[]) => true), + pauseStdout: mock(() => {}), + resumeStdout: mock(() => {}), + stop: mock(() => {}), + kill: mock(() => {}), + alive: () => true, + } + const factory = (args: SuperviseChildOptions): FakeSupervisor => { + 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(() => {}) } + +/** Package a fake WebSocketServer class + supervisor factory into the ACP-face deps bundle. */ +const makeDeps = (WebSocketServer: unknown, superviseChild: unknown): BridgeDeps => + ({ WebSocketServer, superviseChild }) as unknown as BridgeDeps + +const start = (over: Partial = {}) => { + 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: makeDeps(FakeWss, 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!: FakeWssInstance + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o: WssOpts) { + 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: makeDeps(Capture, 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!: FakeWssInstance + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o: WssOpts) { + 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: makeDeps(Capture, 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', () => { + // 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) +}) + +test('--allow-any-origin accepts any Origin (gate disabled)', () => { + 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!: FakeWssInstance + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o: WssOpts) { + 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: makeDeps(Capture, 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!: FakeWssInstance + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o: WssOpts) { + 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: makeDeps(Capture, 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!: FakeWssInstance + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o: WssOpts) { + 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: makeDeps(Capture, 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!: FakeWssInstance + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o: WssOpts) { + 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: makeDeps(Capture, 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!: FakeWssInstance + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o: WssOpts) { + 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: makeDeps(Capture, factory), + }) + wssRef.emit('listening') + await p + // send callback deferred so we control when 'flush' (resume check) fires. + const pending: Array<(() => void) | undefined> = [] + const ws = makeFakeWs() + ws.bufferedAmount = HIGH_WATER + 1 + ws.send = mock((_data: unknown, cb?: () => void) => 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!: FakeWssInstance + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o: WssOpts) { + 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: makeDeps(Capture, 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!: FakeWssInstance + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o: WssOpts) { + 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: makeDeps(Capture, 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!: FakeWssInstance + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o: WssOpts) { + 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: makeDeps(Capture, 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('close() AFTER the child self-exited resolves (no hang) and is idempotent', async () => { + let wssRef!: FakeWssInstance + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o: WssOpts) { + 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: makeDeps(Capture, 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!: FakeWssInstance + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o: WssOpts) { + 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: makeDeps(Capture, 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!: FakeWssInstance + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o: WssOpts) { + 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: makeDeps(Capture, 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) +}) + +test('a spawn error that lands AFTER the face resolved (listen won the race) reaps the child instead of leaving a zombie face', async () => { + let wssRef!: FakeWssInstance + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o: WssOpts) { + super(o) + wssRef = this + } + } + const { sup, factory } = makeFakeSupervisor() + const onChildExit = mock(() => {}) + const logger = { error: () => {}, info: () => {}, warn: mock(() => {}), banner: mock(() => {}) } + const p = startBridge({ + launch: ['x'], + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + logger, + onChildExit, + deps: makeDeps(Capture, factory), + }) + // listen wins the race: the start promise resolves a live face first... + wssRef.emit('listening') + const face = await p + expect(typeof face.url).toBe('string') + // ...then the child's spawn error lands late. The resolved face must NOT be left + // pointing at a dead child: the server is torn down and the caller is told via + // onChildExit, exactly as a self-exit would. Without the once-guard the late + // onSpawnError would wss.close() under the live face and skip onChildExit. + sup.onSpawnError!(Object.assign(new Error('enoent'), { code: 'ENOENT' })) + expect(wssRef.closed).toBe(true) + expect(onChildExit).toHaveBeenCalledTimes(1) + expect(onChildExit).toHaveBeenCalledWith({ code: null, signal: null }) + // Reaping a failed spawn must not SIGKILL — there is no live child to kill. + expect(sup.kill).not.toHaveBeenCalled() + // The single legitimate banner came from the winning listen; the late reap adds none. + expect(logger.banner).toHaveBeenCalledTimes(1) +}) + +test('a spawn error BEFORE listening rejects, and a later listening prints no banner and does not double-settle', async () => { + let wssRef!: FakeWssInstance + const { FakeWss } = makeFakeWss() + class Capture extends FakeWss { + constructor(o: WssOpts) { + super(o) + wssRef = this + } + } + const { sup, factory } = makeFakeSupervisor() + const onChildExit = mock(() => {}) + const logger = { error: () => {}, info: () => {}, warn: mock(() => {}), banner: mock(() => {}) } + const p = startBridge({ + launch: ['x'], + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + logger, + onChildExit, + deps: makeDeps(Capture, factory), + }) + // Spawn fails before the socket is listening: the start promise rejects. A spawn + // failure has no live child, so it must not SIGKILL. + sup.onSpawnError!(Object.assign(new Error('enoent'), { code: 'ENOENT' })) + await expect(p).rejects.toMatchObject({ name: 'UnavailableError', code: 'ENOENT' }) + expect(sup.kill).not.toHaveBeenCalled() + // A 'listening' that fires after the reject is a no-op: no ws:// banner, and the + // already-rejected start is not re-settled into a live face (no reap, no onChildExit). + wssRef.emit('listening') + expect(logger.banner).not.toHaveBeenCalled() + expect(onChildExit).not.toHaveBeenCalled() +}) diff --git a/cli/src/server.ts b/cli/src/server.ts new file mode 100644 index 000000000..05bdf7697 --- /dev/null +++ b/cli/src/server.ts @@ -0,0 +1,208 @@ +// 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. + +import type { AddressInfo } from 'node:net' +import type { WebSocket } from 'ws' +import { UnavailableError } from './errors' +import { buildOriginAllowlist, safeClassifyFrame } from './log' +import { createNdjsonReader, frameToWs, wsToFrame } from './relay' +import { superviseChild as defaultSuperviseChild } from './child' +import { formatHostForUrl, makeCloseLatch } from './util' +import type { ChildExit, StartBridge, WebSocketServerClass } from './types' + +/** 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 + +/** + * 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. + */ +const startBridge: 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: WebSocketServerClass = + deps.WebSocketServer ?? (require('ws') as typeof import('ws')).WebSocketServer + const superviseChild = deps.superviseChild ?? defaultSuperviseChild + const isOriginAllowed = buildOriginAllowlist({ allowOrigins, allowAnyOrigin }) + + return new Promise((resolve, reject) => { + const latch = makeCloseLatch() + + // The start promise settles exactly once. A successful 'listening' resolves a + // live face; a spawn/bind error rejects. This once-guard makes whichever fires + // first win so the loser is a no-op — `latch.settled()` only tracks the CLOSE + // lifecycle, so without it a spawn error landing just AFTER 'listening' resolved + // would tear the server down under an already-resolved face (and silently skip + // onChildExit), leaving the caller holding a live handle to a dead child; and, + // symmetrically, a 'listening' that fires after a spawn error rejected would + // print a spurious ws:// banner. See reapChild + onSpawnError below. + let startSettled = false + + let client: WebSocket | null = null + let paused = false + + const sendToClient = (line: string): void => { + 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', safeClassifyFrame(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 }: { origin?: string }) => isOriginAllowed(origin), + }) + + const finishClose = latch.finishClose + + // Flush the child's stdout, close the client + server, and notify the caller. + // Shared by a normal self-exit and by a spawn error that lands AFTER the face + // already resolved, so a resolved face never silently points at a dead child. + const reapChild = (info: ChildExit): void => { + reader.flush() + if (client) client.close(CLOSE_NORMAL) + wss.close(finishClose) + if (onChildExit) onChildExit(info) + } + + const supervisor = superviseChild({ + launch, + spawn: deps.spawn, + logger, + onStdout: (chunk) => reader.push(chunk), + onExit: reapChild, + onSpawnError: (err) => { + // A spawn failure (ENOENT/EACCES): the child never produced a usable face. + // If 'listening' already won the race and resolved the start promise, reap + // like a self-exit so the caller learns the child is dead and nothing is + // orphaned; otherwise this is the first settle, so reject. + if (startSettled) { + reapChild({ code: null, signal: null }) + return + } + startSettled = true + wss.close() + reject(new UnavailableError({ code: err.code })) + }, + }) + + wss.on('error', (err: NodeJS.ErrnoException) => { + // Bind failures (EADDRINUSE/EACCES) arrive here before 'listening'. If a spawn + // error already won the start race this is a no-op (never double-kill/reject); + // a failed bind never emits 'listening', so the two stay mutually exclusive. + if (startSettled) return + supervisor.kill() // never-orphan + wss.close() + reject(new UnavailableError({ code: err.code })) + }) + + wss.on('connection', (ws: WebSocket) => { + // 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: Buffer) => { + 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', safeClassifyFrame(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', () => { + // If a spawn error already rejected (won the race), the server is being torn + // down — resolving now would hand back a dead face and print a spurious ws:// + // banner, so this callback is a no-op once the start has settled. + if (startSettled) return + startSettled = true + const actualPort = (wss.address() as AddressInfo).port + const url = `ws://${formatHostForUrl(host)}:${actualPort}` + logger.banner(url) + + resolve({ + url, + kill: () => supervisor.kill(), // immediate SIGKILL — never-orphan backstop + close: () => + new Promise((resolveOuter) => { + // 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 (idempotent once gone), never-orphan + wss.close(finishClose) + }), + }) + }) + }) +} + +export { startBridge, HIGH_WATER, LOW_WATER, CLOSE_NORMAL } diff --git a/cli/src/tunnel.test.ts b/cli/src/tunnel.test.ts new file mode 100644 index 000000000..bb340021a --- /dev/null +++ b/cli/src/tunnel.test.ts @@ -0,0 +1,173 @@ +/* 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 { test, expect, mock, type Mock } from 'bun:test' +import { EventEmitter } from 'node:events' +import { startTunnel, generateBearer, BEARER_BYTES } from './tunnel' +import type { SpawnFn } from './types' + +/** Capturing logger that records every event/text line written. */ +const makeLogger = () => { + const lines: string[] = [] + return { + lines, + info: mock((event: string) => lines.push(`info ${event}`)), + warn: mock((event: string) => lines.push(`warn ${event}`)), + error: mock((event: string) => lines.push(`error ${event}`)), + banner: mock((url: string) => lines.push(`banner ${url}`)), + } +} + +/** Fake cloudflared child: EventEmitter with a stderr stream + kill capture. */ +type FakeChild = EventEmitter & { + stderr: EventEmitter + exitCode: number | null + signalCode: NodeJS.Signals | null + signals: (NodeJS.Signals | undefined)[] + kill: Mock<(signal?: NodeJS.Signals) => boolean> +} + +const makeChild = (): FakeChild => { + const child = new EventEmitter() as FakeChild + child.stderr = new EventEmitter() + child.exitCode = null + child.signalCode = null + child.signals = [] + child.kill = mock((sig?: NodeJS.Signals) => { + child.signals.push(sig) + return true + }) + return child +} + +/** A fake child_process.spawn that always returns the given fake cloudflared child. */ +const fakeSpawn = (child: FakeChild): SpawnFn => (() => child) as unknown as SpawnFn + +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', + bearer: 'b', + logger, + spawn: spawn as unknown as SpawnFn, + }) + 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', bearer: 'b', logger, spawn: fakeSpawn(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('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: fakeSpawn(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: number) => { + requestedBytes = n + return Buffer.alloc(n, 3) + } + 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 bearer = 'super-secret-bearer-value' + const promise = startTunnel({ localUrl: 'http://127.0.0.1:5000/mcp', bearer, logger, spawn: fakeSpawn(child) }) + child.stderr.emit('data', Buffer.from('https://secret-tunnel.trycloudflare.com\n')) + const { publicUrl } = 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', bearer: 'b', logger, spawn: fakeSpawn(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/) +}) + +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', + bearer: 'b', + logger, + spawn: fakeSpawn(child), + 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', bearer: 'b', logger, spawn: fakeSpawn(child) }) + 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', bearer: 'b', logger, spawn: fakeSpawn(child) }) + 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('startTunnel returns the caller-supplied bearer verbatim', async () => { + const logger = makeLogger() + const child = makeChild() + const promise = startTunnel({ + localUrl: 'http://127.0.0.1:5000/mcp', + bearer: 'caller-bearer', + logger, + spawn: fakeSpawn(child), + }) + child.stderr.emit('data', Buffer.from('https://x.trycloudflare.com\n')) + const { bearer } = await promise + expect(bearer).toBe('caller-bearer') +}) diff --git a/cli/src/tunnel.ts b/cli/src/tunnel.ts new file mode 100644 index 000000000..1e83f58d5 --- /dev/null +++ b/cli/src/tunnel.ts @@ -0,0 +1,115 @@ +// 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). +// 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. + +import { spawn as defaultSpawn } from 'node:child_process' +import { randomBytes as defaultRandomBytes } from 'node:crypto' +import { UnavailableError } from './errors' +import type { GenerateBearer, StartTunnel } from './types' + +/** 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 +/** 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 + +/** + * 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. + */ +const generateBearer: 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. + */ +const startTunnel: 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]) + + let graceTimer: NodeJS.Timeout | null = null + const clearGrace = () => { + if (graceTimer) { + clearTimeout(graceTimer) + graceTimer = null + } + } + + const close = (): Promise => + new Promise((resolveClose) => { + if (child.exitCode !== null || child.signalCode !== null) { + clearGrace() + 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: NodeJS.ErrnoException) => { + 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. + // 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: Buffer) => { + if (settled.done) return + stderrBuffer = (stderrBuffer + chunk.toString('utf8')).slice(-STDERR_BUFFER_CAP) + const match = TRYCLOUDFLARE_RE.exec(stderrBuffer) + 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 }) + }) + }) +} + +export { startTunnel, generateBearer, BEARER_BYTES } diff --git a/cli/src/types.ts b/cli/src/types.ts new file mode 100644 index 000000000..ee94425b4 --- /dev/null +++ b/cli/src/types.ts @@ -0,0 +1,339 @@ +/* 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/. */ + +// Single source of truth for the cross-module boundary types of the thunderbolt +// bridge. Every type here describes a shape that flows BETWEEN modules (an +// options bag, a returned handle/controller, a callback signature, or a parsed +// frame) — never a module-private implementation detail. This file is purely +// type-level (no runtime values), so esbuild erases it entirely from the bundle. + +import type { ChildProcess } from 'node:child_process' +import type { IncomingMessage, ServerResponse } from 'node:http' + +// --------------------------------------------------------------------------- +// JSON-RPC frames + the NDJSON relay (relay.js, log.js, mcp-multiplexer.js) +// --------------------------------------------------------------------------- + +/** A JSON-RPC request/response id. */ +export type JsonRpcId = string | number + +/** + * A loosely-typed JSON-RPC frame as it crosses the bridge. Every field is + * optional because the same shape covers requests, responses, and + * notifications; payload fields are intentionally `unknown` (the bridge never + * inspects them beyond `method`/`id` routing). + */ +export type JsonRpcMessage = { + jsonrpc?: string + id?: JsonRpcId | null + method?: string + params?: unknown + result?: unknown + error?: unknown +} + +/** Per-line callback the NDJSON reader invokes for each complete line. */ +export type LineHandler = (line: string) => void + +/** + * Incremental NDJSON splitter returned by `createNdjsonReader`. `push` feeds a + * raw chunk; `flush` emits any trailing unterminated line. + */ +export type NdjsonReader = { + push: (chunk: Buffer | string) => void + flush: () => void +} + +/** Non-identifying shape token for a frame's id (never the id value itself). */ +export type FrameIdKind = 'request' | 'response' | 'notification' | 'absent' + +/** PII-safe classification of a frame: only its method name + id shape token. */ +export type ClassifiedFrame = { + method: string + id: FrameIdKind +} + +// --------------------------------------------------------------------------- +// Exit codes + error options (errors.js) +// --------------------------------------------------------------------------- + +/** Canonical sysexits exit codes the bridge can produce. */ +export type ExitCode = 0 | 64 | 69 | 70 | 130 + +/** A child process's terminal outcome, as reported by the supervisor. */ +export type ChildExit = { + code: number | null + signal: NodeJS.Signals | null +} + +/** Constructor options for an `UnavailableError` (carries a Node error code). */ +export type UnavailableErrorOptions = { + code?: string + message?: string +} + +// --------------------------------------------------------------------------- +// Logger (log.js) +// --------------------------------------------------------------------------- + +/** + * Structured fields a log call may carry. `undefined` values are tolerated (the + * logger drops non-scalars, including `undefined`, before emitting). + */ +export type LogFields = Record + +/** PII-safe structured logger written to the injected stderr sink. */ +export type Logger = { + info: (event: string, fields?: LogFields) => void + warn: (event: string, fields?: LogFields) => void + error: (event: string, fields?: LogFields) => void + banner: (url: string) => void +} + +/** Options for `makeLogger`. */ +export type LoggerOptions = { + json: boolean + verbose: boolean + sink: NodeJS.WritableStream +} + +/** Options for `buildOriginAllowlist`. */ +export type OriginAllowlistOptions = { + allowOrigins: string[] + allowAnyOrigin: boolean +} + +/** The Origin gate predicate returned by `buildOriginAllowlist`. */ +export type OriginAllowlist = (origin: string | undefined) => boolean + +// --------------------------------------------------------------------------- +// Parsed CLI args (args.js) +// --------------------------------------------------------------------------- + +/** The bridge face the child stdio server is exposed as. */ +export type Mode = 'acp' | 'mcp' + +/** A fully-resolved `bridge` options object (the parser's success result). */ +export type ParsedArgs = { + mode: Mode + host: string + port: number + allowOrigins: string[] + allowAnyOrigin: boolean + tunnel: boolean + json: boolean + verbose: boolean + launch: string[] +} + +/** Resolved bridge opts tagged with their subcommand for the dispatcher. */ +export type BridgeCommand = ParsedArgs & { command: 'bridge' } + +/** A request to print help text, keyed by the help topic. */ +export type HelpIntent = { help: 'root' | 'bridge' } + +/** A request to print the version. */ +export type VersionIntent = { version: true } + +/** What `parseBridgeArgs` returns: resolved opts or a help/version intent. */ +export type ParseBridgeResult = ParsedArgs | { help: 'bridge' } | VersionIntent + +/** What `parseArgs` returns: a resolved subcommand or a help/version intent. */ +export type ParseArgsResult = BridgeCommand | HelpIntent | VersionIntent + +// --------------------------------------------------------------------------- +// Shared util seams (util.js) +// --------------------------------------------------------------------------- + +/** + * A resolve-once close latch shared by both faces so never-orphan teardown + * semantics can't drift between them. + */ +export type CloseLatch = { + finishClose: () => void + setResolver: (fn: () => void) => void + settled: () => boolean +} + +/** Inputs to `insecureFlagWarnings` (a subset of the parsed bridge args). */ +export type InsecureFlagOptions = { + host: string + allowAnyOrigin: boolean + tunnel: boolean +} + +// --------------------------------------------------------------------------- +// Injectable runtime collaborators +// --------------------------------------------------------------------------- + +/** Injectable `child_process.spawn`. */ +export type SpawnFn = typeof import('node:child_process').spawn + +/** Injectable `http.createServer`. */ +export type CreateServerFn = typeof import('node:http').createServer + +/** Injectable `ws` `WebSocketServer` constructor. */ +export type WebSocketServerClass = typeof import('ws').WebSocketServer + +// --------------------------------------------------------------------------- +// Child supervisor (child.js) +// --------------------------------------------------------------------------- + +/** Options for `superviseChild`. */ +export type SuperviseChildOptions = { + launch: string[] + spawn?: SpawnFn + onStdout: (chunk: Buffer) => void + onExit: (info: ChildExit) => void + onSpawnError: (err: NodeJS.ErrnoException) => void + logger: Pick + graceMs?: number +} + +/** + * The controller `superviseChild` returns: the spawned child plus the controls + * the ACP/MCP faces drive it with (stdin write with backpressure return, + * pause/resume stdout, graceful stop, immediate kill, liveness probe). + */ +export type ChildSupervisor = { + child: ChildProcess + writeStdin: (chunk: string | Buffer) => boolean + pauseStdout: () => void + resumeStdout: () => void + stop: (signal?: NodeJS.Signals) => void + kill: () => void + alive: () => boolean +} + +/** The `superviseChild` function signature (used as an injectable dep). */ +export type SuperviseChild = (opts: SuperviseChildOptions) => ChildSupervisor + +// --------------------------------------------------------------------------- +// MCP transports (mcp-multiplexer.js, mcp-server.js) +// --------------------------------------------------------------------------- + +/** + * The structural slice of a `StreamableHTTPServerTransport` the bridge drives: + * the inbound `onmessage` hook, outbound `send`, `close`, and the HTTP + * `handleRequest` entry. Kept structural so the multiplexer never couples to the + * SDK directly (it receives the class as a parameter). + */ +export type McpTransport = { + onmessage?: (message: JsonRpcMessage) => void + send: (message: JsonRpcMessage) => Promise + close: () => void + handleRequest: (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise +} + +/** A stateless-transport constructor (the SDK class or an injected fake). */ +export type McpTransportClass = new (opts: { sessionIdGenerator: undefined }) => McpTransport + +// --------------------------------------------------------------------------- +// MCP multiplexer (mcp-multiplexer.js) +// --------------------------------------------------------------------------- + +/** Options for `createMultiplexer`. */ +export type MultiplexerOptions = { + writeChild: (frame: string) => void + logger: Pick +} + +/** + * The bridge-owned multiplexer that fans many per-request transports onto the + * one stdio child: mints transports, releases them, routes child stdout back to + * the owning client(s), and closes everything on teardown. + */ +export type Multiplexer = { + createTransport: (TransportClass: McpTransportClass) => McpTransport + releaseTransport: (transport: McpTransport) => void + onChildMessage: (message: JsonRpcMessage) => void + closeAll: () => void +} + +// --------------------------------------------------------------------------- +// Faces: ACP WebSocket (server.js) + MCP Streamable HTTP (mcp-server.js) +// --------------------------------------------------------------------------- + +/** The handle both faces resolve with: the bound URL plus teardown controls. */ +export type FaceHandle = { + url: string + kill: () => void + close: () => Promise +} + +/** Injectable collaborators for the ACP face. */ +export type BridgeDeps = { + WebSocketServer?: WebSocketServerClass + spawn?: SpawnFn + superviseChild?: SuperviseChild +} + +/** Options for `startBridge` (the ACP WebSocket face). */ +export type BridgeOptions = { + launch: string[] + host: string + port: number + allowOrigins: string[] + allowAnyOrigin: boolean + logger: Logger + onChildExit?: (info: ChildExit) => void + deps?: BridgeDeps +} + +/** Injectable collaborators for the MCP face. */ +export type McpFaceDeps = { + createServer?: CreateServerFn + StreamableHTTPServerTransport?: McpTransportClass + superviseChild?: SuperviseChild + spawn?: SpawnFn +} + +/** Options for `startMcpFace` (the MCP Streamable HTTP face). */ +export type McpFaceOptions = { + launch: string[] + host: string + port: number + bearer?: string + allowOrigins: string[] + allowAnyOrigin: boolean + bodyCapBytes?: number + logger: Logger + onChildExit?: (info: ChildExit) => void + deps?: McpFaceDeps +} + +/** The `startBridge` function signature (used as an injectable dep). */ +export type StartBridge = (opts: BridgeOptions) => Promise + +/** The `startMcpFace` function signature (used as an injectable dep). */ +export type StartMcpFace = (opts: McpFaceOptions) => Promise + +// --------------------------------------------------------------------------- +// Cloudflared tunnel (tunnel.js) +// --------------------------------------------------------------------------- + +/** Options for `startTunnel`. */ +export type TunnelOptions = { + localUrl: string + bearer: string + logger: Logger + spawn?: SpawnFn + urlTimeoutMs?: number +} + +/** The handle `startTunnel` resolves with once cloudflared reports its URL. */ +export type TunnelHandle = { + publicUrl: string + bearer: string + close: () => Promise +} + +/** The `startTunnel` function signature (used as an injectable dep). */ +export type StartTunnel = (opts: TunnelOptions) => Promise + +/** The `generateBearer` function signature (used as an injectable dep). */ +export type GenerateBearer = (randomBytes?: (size: number) => Buffer) => string + +/** The `makeLogger` function signature (used as an injectable dep). */ +export type MakeLogger = (opts: LoggerOptions) => Logger diff --git a/cli/src/util.test.ts b/cli/src/util.test.ts new file mode 100644 index 000000000..18f341fbe --- /dev/null +++ b/cli/src/util.test.ts @@ -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/. */ + +import { test, expect } from 'bun:test' +import { resolvePort, formatHostForUrl, isLoopbackHost, insecureFlagWarnings } from './util' +import { UsageError } from './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 fires a loud DANGER line for a non-loopback host alone (no --allow-any-origin)', () => { + const warnings = insecureFlagWarnings({ host: '0.0.0.0', allowAnyOrigin: false, tunnel: false }) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain('DANGER') + expect(warnings[0]).toContain('0.0.0.0') +}) + +test('insecureFlagWarnings has BOTH the DANGER non-loopback line and the allow-any-origin line for allowAnyOrigin + non-loopback host', () => { + const warnings = insecureFlagWarnings({ host: '0.0.0.0', allowAnyOrigin: true, tunnel: false }) + expect(warnings).toHaveLength(2) + expect(warnings[0]).toContain('DANGER') + expect(warnings[0]).toContain('0.0.0.0') + expect(warnings[1]).toContain('--allow-any-origin') +}) + +test('insecureFlagWarnings DANGER line drops the unauthenticated claim when tunnel:true (bearer gates the bind)', () => { + const warnings = insecureFlagWarnings({ host: '0.0.0.0', allowAnyOrigin: false, tunnel: true }) + const danger = warnings.find((w) => w.includes('DANGER')) + expect(danger).toContain('0.0.0.0') + expect(danger).not.toContain('unauthenticated') + expect(danger).toContain('bearer') +}) + +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(3) + expect(warnings.every((w) => typeof w === 'string')).toBe(true) +}) diff --git a/cli/src/util.ts b/cli/src/util.ts new file mode 100644 index 000000000..fe07f90ae --- /dev/null +++ b/cli/src/util.ts @@ -0,0 +1,101 @@ +/* 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 { UsageError } from './errors' +import type { CloseLatch, InsecureFlagOptions } from './types' + +/** + * 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. + */ +const resolvePort = (raw: string | number | undefined): number => { + 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. + */ +const formatHostForUrl = (host: string): string => { + 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. + */ +const isLoopbackHost = (host: string): boolean => { + 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) +} + +/** + * 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. 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. + */ +const makeCloseLatch = (): CloseLatch => { + let settled = false + let resolveClose: (() => void) | null = null + return { + finishClose: () => { + if (settled) return + settled = true + if (resolveClose) resolveClose() + }, + setResolver: (fn: () => void) => { + if (settled) { + fn() + return + } + 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 + * this stays pure and testable. + */ +const insecureFlagWarnings = ({ host, allowAnyOrigin, tunnel }: InsecureFlagOptions): string[] => { + const warnings: string[] = [] + if (!isLoopbackHost(host)) { + // --tunnel mints a mandatory bearer (cli.ts) that is enforced bearer-before-route + // on the bound face, so a non-loopback bind under --tunnel IS authenticated; only + // the bearerless local mode is reachable unauthenticated. + const authClause = tunnel + ? ', though the mandatory --tunnel bearer still gates every request' + : ' and local mode has no bearer, so the server is reachable unauthenticated' + warnings.push( + `DANGER: binding to non-loopback host "${host}" exposes the face to your network — clients without an Origin header (curl, local tools) bypass the Origin gate${authClause}.`, + ) + } + 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 +} + +export { resolvePort, formatHostForUrl, isLoopbackHost, insecureFlagWarnings, makeCloseLatch } diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 000000000..32fdee040 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "esModuleInterop": true, + "types": ["node", "bun"], + "skipLibCheck": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["bin/**/*.ts", "src/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/e2e/acp-agents-catalog.spec.ts b/e2e/acp-agents-catalog.spec.ts index 960849e9d..80d7afd43 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('thunderbolt 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('thunderbolt bridge --mode acp --', { exact: false })).toHaveCount(0) + + expect(errors).toEqual([]) + }) }) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 53d6d4861..153b5afa8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -94,6 +94,123 @@ pub fn capabilities() -> Capabilities { } } +// === Thunderbolt bridge installer ============================================================ + +/// The inner `curl … | bash` pipe that installs the `thunderbolt` bridge onto the +/// user's PATH. This is the raw pipeline only — the `install_bridge` call site below +/// wraps it in `bash -c 'set -o pipefail; …'`. That wrapped form is what the connect +/// dialog shows for manual install (`installCommand` in +/// `src/lib/agent-bridge-command.ts`); keep this pipe in sync with the pipe inside +/// that command. +const THUNDERBOLT_INSTALL_CMD: &str = + "curl -fsSL https://raw.githubusercontent.com/thunderbird/thunderbolt/main/cli/install.sh | bash"; + +/// Maps a finished installer process to the renderer-facing result: trimmed stdout +/// on a clean exit, otherwise a message built from stderr (falling back to stdout) +/// and the exit code. Pulled out of the command so it's unit-testable without +/// actually spawning a shell. +fn map_install_output(output: std::process::Output) -> Result { + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + Err(format!( + "installer exited with status {}: {}", + output.status.code().unwrap_or(-1), + detail + )) +} + +/// Runs the `thunderbolt` bridge installer from the desktop connect dialog so the user can +/// install without a terminal. install.sh needs `node`/`npm`/`curl`, so we run it +/// through the user's login shell (`$SHELL -lc`, falling back to `bash`) to pick up +/// their PATH (nvm/brew node), off the async runtime so the UI stays responsive. +/// Best-effort: when the GUI environment lacks node/npm it fails and the dialog +/// surfaces the manual command. macOS/Linux only — Windows and mobile have no POSIX +/// login shell to drive the script. +#[cfg(all(desktop, unix))] +#[command] +pub async fn install_bridge() -> Result { + let output = tauri::async_runtime::spawn_blocking(|| { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); + // Two-layer invocation, each layer doing one job: + // - Outer `$SHELL -lc` runs as a login shell to load the user's PATH + // (nvm/brew node) — but `$SHELL` may be fish or another non-POSIX shell. + // - Inner `bash -c` provides `set -o pipefail`, which is bash/zsh-only + // syntax that fish would reject. `bash -c '...'` is a plain command every + // login shell can exec, and the inner bash inherits the login PATH. + // pipefail makes a failed `curl` (404 / no network) fail the whole pipeline + // instead of `bash` succeeding on empty stdin — otherwise a broken download + // reports a false "installed". THUNDERBOLT_INSTALL_CMD has no single quotes, so the + // single-quoted nesting stays clean. + std::process::Command::new(shell) + .arg("-lc") + .arg(format!( + "bash -c 'set -o pipefail; {THUNDERBOLT_INSTALL_CMD}'" + )) + .output() + }) + .await + .map_err(|e| format!("installer task failed: {e}"))? + .map_err(|e| format!("failed to spawn installer: {e}"))?; + + map_install_output(output) +} + +/// Windows desktop and mobile have no POSIX login shell to drive install.sh, so +/// auto-install is unavailable there; the dialog shows the manual command instead. +#[cfg(not(all(desktop, unix)))] +#[command] +pub async fn install_bridge() -> Result { + Err( + "Automatic install is only available on macOS and Linux. Use the manual command." + .to_string(), + ) +} + +#[cfg(all(test, unix))] +mod install_bridge_tests { + use super::map_install_output; + use std::os::unix::process::ExitStatusExt; + use std::process::{ExitStatus, Output}; + + // On Unix the raw wait-status encodes the exit code in the high byte. + fn output(code: i32, stdout: &str, stderr: &str) -> Output { + Output { + status: ExitStatus::from_raw(code << 8), + stdout: stdout.into(), + stderr: stderr.into(), + } + } + + #[test] + fn success_returns_trimmed_stdout() { + assert_eq!( + map_install_output(output(0, "Installed.\n", "")).unwrap(), + "Installed." + ); + } + + #[test] + fn failure_prefers_stderr_and_code() { + let err = map_install_output(output(1, "", "npm not found")).unwrap_err(); + assert!(err.contains("status 1")); + assert!(err.contains("npm not found")); + } + + #[test] + fn failure_falls_back_to_stdout_when_stderr_empty() { + let err = map_install_output(output(2, "some detail", "")).unwrap_err(); + assert!(err.contains("some detail")); + } +} + // === OAuth loopback server =================================================================== /// Ports pre-registered as redirect URIs in the Google / Microsoft OAuth console. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5197284a1..4fb8142a6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -48,6 +48,7 @@ pub fn create_app() -> tauri::Builder { commands::capabilities, commands::set_interface_style, commands::start_oauth_server, + commands::install_bridge, ]); #[cfg(debug_assertions)] diff --git a/src/acp/transports/index.test.ts b/src/acp/transports/index.test.ts index dca112df4..b3f7bfd53 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 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..bf2cb5482 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 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..d0e679f16 --- /dev/null +++ b/src/acp/transports/is-loopback.test.ts @@ -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/. */ + +import { describe, expect, it } from 'bun:test' +import { isLoopbackHost, isLoopbackUrl } from './is-loopback' + +describe('isLoopbackHost', () => { + it('classifies the loopback hostnames', () => { + expect(isLoopbackHost('127.0.0.1')).toBe(true) + expect(isLoopbackHost('::1')).toBe(true) + expect(isLoopbackHost('localhost')).toBe(true) + }) + + it('treats the whole 127.0.0.0/8 block as loopback (matches the bridge classifier)', () => { + // A bridge bound with e.g. `--host 127.0.0.2` must connect directly, not via the proxy. + expect(isLoopbackHost('127.0.0.2')).toBe(true) + expect(isLoopbackHost('127.1.2.3')).toBe(true) + expect(isLoopbackHost('127.255.255.255')).toBe(true) + }) + + it('rejects malformed 127.* literals (out-of-range octets, wrong arity)', () => { + expect(isLoopbackHost('127.0.0.256')).toBe(false) + expect(isLoopbackHost('127.0.0')).toBe(false) + expect(isLoopbackHost('127.0.0.1.1')).toBe(false) + }) + + 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', () => { + 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('rejects hosts that only resemble the 127 prefix (exact first-octet match)', () => { + expect(isLoopbackHost('128.0.0.1')).toBe(false) // off-by-one first octet + expect(isLoopbackHost('1270.0.0.1')).toBe(false) // '1270' is not the literal '127' + expect(isLoopbackHost('27.0.0.1')).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('http://LOCALHOST/x')).toBe(true) + }) + + it('classifies a non-127.0.0.1 host in the 127.0.0.0/8 block as loopback', () => { + expect(isLoopbackUrl('ws://127.0.0.2:8080')).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) + }) + + 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..7dc2c2bf9 --- /dev/null +++ b/src/acp/transports/is-loopback.ts @@ -0,0 +1,61 @@ +/* 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 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. + */ + +/** Non-IPv4 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`. The entire `127.0.0.0/8` IPv4 block is loopback but checked + * separately (see `isIpv4Loopback`). `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 (`cli/src/util.ts`, which accepts + * 127.0.0.0/8, ::1, localhost). */ +const loopbackHosts = new Set(['::1', 'localhost']) + +/** True when `host` is an IPv4 literal in the `127.0.0.0/8` loopback block (each + * octet 0..255). Mirrors the bridge's classifier so a bridge bound with e.g. + * `--host 127.0.0.2` is connected to directly, not routed through the universal + * proxy (which rejects loopback). */ +const isIpv4Loopback = (host: string): boolean => { + const octets = host.split('.') + return ( + octets.length === 4 && + octets[0] === '127' && + octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255) + ) +} + +/** + * 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. Any IPv4 + * literal in `127.0.0.0/8` (not just `127.0.0.1`) counts as loopback. + */ +export const isLoopbackHost = (host: string): boolean => { + const normalized = host.toLowerCase().replace(/^\[(.+)\]$/, '$1') + return loopbackHosts.has(normalized) || isIpv4Loopback(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..cf2c9a0a5 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,83 @@ 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 thunderbolt bridge hint by default (no URL entered)', () => { + renderDialog() + expect(screen.getByText(/thunderbolt 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() + }) + + it('does NOT show the "connects directly" hint for a ws:// loopback on iOS (ATS rejects it)', () => { + render( + {}} + onSubmit={async () => {}} + isIos={() => true} + testAcpConnection={async () => ({ success: true })} + />, + ) + fireEvent.change(screen.getByLabelText(/url/i), { target: { value: 'ws://127.0.0.1:8080' } }) + + // The URL is rejected on iOS, so the reassuring loopback hint must not show — + // the inline secure-URL error is surfaced instead. + expect(screen.queryByTestId('agent-url-loopback-hint')).not.toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent(/secure/i) + }) + + it('still shows the "connects directly" hint for a wss:// loopback on iOS', () => { + render( + {}} + onSubmit={async () => {}} + isIos={() => true} + testAcpConnection={async () => ({ success: true })} + />, + ) + fireEvent.change(screen.getByLabelText(/url/i), { target: { value: 'wss://127.0.0.1:8080' } }) + expect(screen.getByTestId('agent-url-loopback-hint')).toHaveTextContent(/connects directly/i) + }) +}) + 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..b0ea0714f 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' @@ -176,6 +177,11 @@ export const AddCustomAgentDialog = ({ // 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 + // A loopback URL is a local bridge endpoint — reassure the user it connects + // directly (no cloud proxy). Only when the URL is also valid for this platform: + // on iOS, a cleartext `ws://127.0.0.1` loopback is rejected by ATS, so showing + // "connects directly" while Test/Save stay blocked would be misleading. + const isLoopbackTarget = trimmedUrl.length > 0 && !urlError && isLoopbackUrl(trimmedUrl) // Submit is gated behind a successful Test Connection — a valid name, URL, // and a confirmed connection are all required before saving. const canSubmit = @@ -254,6 +260,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 thunderbolt 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..de00a1776 --- /dev/null +++ b/src/components/settings/agents/bridge-connect-dialog.test.tsx @@ -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/. */ + +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('thunderbolt 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('thunderbolt 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("bash -c 'set -o pipefail; 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..f8811382c --- /dev/null +++ b/src/components/settings/agents/bridge-connect-dialog.tsx @@ -0,0 +1,101 @@ +/* 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 { Button } from '@/components/ui/button' +import { Dialog } from '@/components/ui/dialog' +import { + ResponsiveModalContentComposable, + ResponsiveModalDescription, + ResponsiveModalHeader, + ResponsiveModalTitle, +} from '@/components/ui/responsive-modal' +import { composeBridgeCommand } from '@/lib/agent-bridge-command' +import type { RegistryEntry } from '@/types/registry' +import { BridgeInstallStep } from './bridge-install-step' +import { Step } from './bridge-connect-step' +import { CopyableCommand } from './copyable-command' + +type BridgeConnectDialogProps = { + entry: RegistryEntry + open: boolean + onOpenChange: (open: boolean) => void +} + +/** 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 + * 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) + + 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/bridge-connect-step.tsx b/src/components/settings/agents/bridge-connect-step.tsx new file mode 100644 index 000000000..5177f5659 --- /dev/null +++ b/src/components/settings/agents/bridge-connect-step.tsx @@ -0,0 +1,24 @@ +/* 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 type { ReactNode } from 'react' + +type StepProps = { + index: number + title: string + children: ReactNode +} + +/** A numbered step (badge + title + body) shared by the ACP and MCP bridge connect dialogs. */ +export const Step = ({ index, title, children }: StepProps) => ( +
+ + {index} + +
+

{title}

+ {children} +
+
+) diff --git a/src/components/settings/agents/bridge-install-step.test.tsx b/src/components/settings/agents/bridge-install-step.test.tsx new file mode 100644 index 000000000..4368619a6 --- /dev/null +++ b/src/components/settings/agents/bridge-install-step.test.tsx @@ -0,0 +1,53 @@ +/* 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, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, mock } from 'bun:test' +import { BridgeInstallStep } from './bridge-install-step' + +afterEach(cleanup) + +describe('BridgeInstallStep', () => { + it('on web shows only the manual install command (no auto button)', () => { + render() + + expect(screen.getByText(/curl -fsSL/)).toBeInTheDocument() + expect(screen.queryByTestId('bridge-install-auto')).not.toBeInTheDocument() + }) + + it('on desktop shows the auto-install button plus a manual fallback', () => { + render( Promise.resolve('ok')} />) + + expect(screen.getByTestId('bridge-install-auto')).toHaveTextContent(/install automatically/i) + // The manual command is still available (in the collapsible fallback). + expect(screen.getByText(/curl -fsSL/)).toBeInTheDocument() + }) + + it('runs the installer and shows "Installed" on success', async () => { + const installFn = mock(() => Promise.resolve('installed to /usr/local/bin/thunderbolt')) + render() + + await act(async () => { + fireEvent.click(screen.getByTestId('bridge-install-auto')) + }) + + expect(installFn).toHaveBeenCalledTimes(1) + await waitFor(() => expect(screen.getByTestId('bridge-install-auto')).toHaveTextContent(/installed/i)) + expect(screen.getByTestId('bridge-install-auto')).toBeDisabled() + }) + + it('surfaces the installer error and keeps the manual fallback', async () => { + const installFn = mock(() => Promise.reject(new Error('installer exited with status 1: npm bin not writable'))) + render() + + await act(async () => { + fireEvent.click(screen.getByTestId('bridge-install-auto')) + }) + + await waitFor(() => expect(screen.getByTestId('bridge-install-error')).toBeInTheDocument()) + expect(screen.getByTestId('bridge-install-error')).toHaveTextContent(/npm bin not writable/) + expect(screen.getByText(/curl -fsSL/)).toBeInTheDocument() + }) +}) diff --git a/src/components/settings/agents/bridge-install-step.tsx b/src/components/settings/agents/bridge-install-step.tsx new file mode 100644 index 000000000..5166ad6cf --- /dev/null +++ b/src/components/settings/agents/bridge-install-step.tsx @@ -0,0 +1,111 @@ +/* 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 } from 'react' +import { Check, Download, Loader2, type LucideIcon } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { composeInstallCommand } from '@/lib/agent-bridge-command' +import { installBridge } from '@/lib/bridge-install' +import { getPlatform } from '@/lib/platform' +import { CopyableCommand } from './copyable-command' + +/** + * Auto-install shells out to a POSIX login shell to run `install.sh` (which needs + * node/npm/curl), so it's only offered on macOS/Linux desktops. Windows desktop + * and web fall back to the manual `curl | bash` command. + */ +const canAutoInstall = (): boolean => { + const os = getPlatform() + return os === 'macos' || os === 'linux' +} + +type InstallState = + | { phase: 'idle' } + | { phase: 'installing' } + | { phase: 'done' } + | { phase: 'error'; message: string } + +// Icon + label per install phase. `error` reuses the idle affordance: the button +// re-enables so the click retries. +const phaseIcon: Record = { + idle: Download, + installing: Loader2, + done: Check, + error: Download, +} +const phaseLabel: Record = { + idle: 'Install automatically', + installing: 'Installing…', + done: 'Installed', + error: 'Install automatically', +} + +type BridgeInstallStepProps = { + /** Test seams — default to real platform detection and the Tauri installer. */ + autoInstallable?: boolean + installFn?: () => Promise +} + +/** + * The "install the bridge" step shared by the ACP and MCP connect dialogs. Where + * auto-install isn't available (web, Windows desktop) it shows the manual + * `curl | bash` command. On macOS/Linux desktop it adds an "Install automatically" + * button that runs the installer through a Rust command, falling back to the manual + * command — auto on success, surfaced inline on error. + */ +export const BridgeInstallStep = ({ + autoInstallable = canAutoInstall(), + installFn = installBridge, +}: BridgeInstallStepProps = {}) => { + const [state, setState] = useState({ phase: 'idle' }) + + if (!autoInstallable) { + return + } + + const runInstall = async () => { + setState({ phase: 'installing' }) + try { + await installFn() + setState({ phase: 'done' }) + } catch (err) { + setState({ phase: 'error', message: err instanceof Error ? err.message : String(err) }) + } + } + + const failed = state.phase === 'error' + const Icon = phaseIcon[state.phase] + + return ( +
+ + {failed && ( +

+ {state.message} +

+ )} +
+ + Install manually + +
+ +
+
+
+ ) +} 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..52a4d0b36 --- /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('thunderbolt 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/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..2895f34e7 --- /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('thunderbolt 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('thunderbolt 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..96f82a910 --- /dev/null +++ b/src/components/settings/mcp-servers/mcp-bridge-connect-dialog.tsx @@ -0,0 +1,91 @@ +/* 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 } 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 { composeMcpBridgeCommand } from '@/lib/agent-bridge-command' +import { BridgeInstallStep } from '@/components/settings/agents/bridge-install-step' +import { Step } from '@/components/settings/agents/bridge-connect-step' +import { CopyableCommand } from '@/components/settings/agents/copyable-command' + +type McpBridgeConnectDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void +} + +/** + * Walks the user through exposing a local stdio MCP server through the `thunderbolt + * 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 new file mode 100644 index 000000000..7b9c27a74 --- /dev/null +++ b/src/lib/agent-bridge-command.test.ts @@ -0,0 +1,133 @@ +/* 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, + composeMcpBridgeCommand, +} 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), 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 bridge --mode acp -- npx @google/gemini-cli@0.46.0 --acp') + expect(command?.startsWith('thunderbolt 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 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 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 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() + }) +}) + +describe('composeMcpBridgeCommand', () => { + it('wraps a stdio command in the bridge command (--mode mcp)', () => { + const command = composeMcpBridgeCommand('npx @modelcontextprotocol/server-everything stdio') + expect(command).toBe('thunderbolt bridge --mode mcp -- npx @modelcontextprotocol/server-everything stdio') + expect(command?.startsWith('thunderbolt bridge --mode mcp')).toBe(true) + }) + + it('trims surrounding whitespace from the command', () => { + expect(composeMcpBridgeCommand(' uvx mcp-server ')).toBe('thunderbolt 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( + "thunderbolt 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('wraps the curl | bash installer in bash -c set -o pipefail so a failed curl fails the pipeline', () => { + // Pin the exact string: a chopped closing quote would break the pasted command, and the + // bash -c 'set -o pipefail; …' wrapper is the contract that propagates a failed curl. + expect(composeInstallCommand()).toBe( + "bash -c 'set -o pipefail; curl -fsSL https://raw.githubusercontent.com/thunderbird/thunderbolt/main/cli/install.sh | bash'", + ) + }) +}) diff --git a/src/lib/agent-bridge-command.ts b/src/lib/agent-bridge-command.ts new file mode 100644 index 000000000..359a574d8 --- /dev/null +++ b/src/lib/agent-bridge-command.ts @@ -0,0 +1,115 @@ +/* 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 bridge connect flow. + * + * A catalogue agent is a local CLI (npx / uvx / binary). To reach it from the + * app the user runs `thunderbolt 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 { isLoopbackUrl } from '@/acp/transports/is-loopback' +import type { RegistryEntry } from '@/types/registry' + +/** The command name the app's `install.sh` installs onto PATH. The bridge is a + * subcommand of this binary (`thunderbolt bridge …`). */ +const bridgeBin = 'thunderbolt' + +/** Canonical one-line installer, wrapped in `bash -c 'set -o pipefail; …'` so a + * failed `curl` (404 / network) fails the whole pipeline instead of leaving the + * exit status at 0 — without pipefail a broken download looks like a successful + * install. The `bash -c` wrapper also makes the pasted command shell-agnostic + * (bash/zsh/fish all just exec it). This is the exact effective auto-install + * command the desktop runs via `THUNDERBOLT_INSTALL_CMD` in `src-tauri/src/commands.rs` + * (which wraps the same script in `bash -c 'set -o pipefail; …'`); keep in sync. */ +const installCommand = + "bash -c 'set -o pipefail; curl -fsSL https://raw.githubusercontent.com/thunderbird/thunderbolt/main/cli/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 + +/** + * 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 + } +} + +/** + * Build a `thunderbolt bridge --mode -- ` command for an already-resolved + * launch fragment, or `null` when there's nothing to wrap. `thunderbolt` 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 request, so + * we add `--allow-origin ''`. A loopback origin (or none) needs nothing + * extra — the default allowlist already accepts loopback. + */ +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 ${mode} ${allowOrigin}-- ${launch}` +} + +/** + * The full ACP bridge command for a catalogue agent: `thunderbolt 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: `thunderbolt 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/lib/bridge-install.ts b/src/lib/bridge-install.ts new file mode 100644 index 000000000..9f9a6a423 --- /dev/null +++ b/src/lib/bridge-install.ts @@ -0,0 +1,13 @@ +/* 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 { invoke } from '@tauri-apps/api/core' + +/** + * Runs the desktop bridge installer — the Rust `install_bridge` command, which + * executes `cli/install.sh` through a login shell and drops the `thunderbolt` binary on + * the user's PATH. Resolves with the installer's stdout, rejects with its error + * message. Desktop only; callers gate on `isDesktop()`. + */ +export const installBridge = (): Promise => invoke('install_bridge') 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..c044819f9 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,54 @@ 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 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)) { + 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 // 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 proxyFetch +} + +/** + * 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 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 = 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}

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