-
Notifications
You must be signed in to change notification settings - Fork 320
feat: thunderbolt — stdio-bridge CLI (thunderbolt bridge), curl|bash installer, and in-app connect flows #1021
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
45 commits
Select commit
Hold shift + click to select a range
4e4382b
feat(THU-601): add thunderbolt-stdio-bridge CLI and curl|bash installer
ital0 eca4e20
ci(THU-601): build, smoke-test and release stdio-bridge on tag
ital0 1fead79
feat(THU-601): connect local ACP/MCP agents via stdio-bridge from the…
ital0 43e3f3a
refactor(THU-601): share bridge teardown latch and frame classifier, …
ital0 204d3fb
fix(THU-601): tunnel real bound port, reject disallowed MCP origins, …
ital0 91324ac
fix(THU-601): run installed bridge directly, scope app origin, verify…
ital0 472ecd6
fix(THU-601): allow Mcp-Protocol-Version in MCP CORS preflight
ital0 f02f09f
fix(THU-601): drop duplicate shebang so the bundled bridge.cjs runs
ital0 8af4c9e
chore: gitignore .playwright-mcp scratch snapshots
ital0 3b2b80d
fix(THU-601): multiplex MCP sessions so concurrent clients share one …
ital0 443d906
docs(zeus): plan rename of thunderbolt-stdio-bridge to zeus
ital0 d961a22
refactor(THU-601): rename bridge CLI package to zeus with a bridge su…
ital0 d359c75
refactor(THU-601): point app connect flow and in-app tutorial at zeus…
ital0 677c4ee
ci(THU-601): rename bridge release workflow to zeus-release
ital0 db879ab
refactor(THU-601): dispatch zeus subcommands by name so future comman…
ital0 dd26c7e
fix(THU-601): pass child --help/--version past the -- delimiter to th…
ital0 5ccfa73
chore(THU-601): rename ZEUS_BIN_DIR, sync lockfile name, guard subcom…
ital0 fd51670
feat(THU-601): add a connect-local-MCP-server-via-bridge flow to the …
ital0 892b9e4
feat(THU-601): add desktop auto-install button to the bridge connect …
ital0 7fe7ff5
refactor(THU-601): extract shared bridge Step and link install-comman…
ital0 f2fac2e
fix(THU-601): gate bridge auto-install to unix desktop and run via th…
ital0 7e3ba5f
fix(THU-601): set pipefail so a failed installer download isn't a fal…
ital0 874596e
fix(THU-601): drop unused mcp-server imports and annotate the allowli…
ital0 a13008c
fix(THU-601): classify the whole 127.0.0.0/8 block as loopback to mat…
ital0 86037d5
fix(THU-601): hide the loopback hint when the URL is rejected on the …
ital0 8acba78
chore(THU-601): place the CORS nosemgrep on the matched line so the s…
ital0 5cd2e1a
chore(THU-601): use a bare nosemgrep on the allowlisted-CORS reflecti…
ital0 a7013af
fix(THU-601): run the auto-install pipefail under bash so non-bash lo…
ital0 9b25b5c
fix(THU-601): make the zeus release publish step idempotent on re-runs
ital0 22195fc
fix(THU-601): wrap the manual install command in pipefail so a failed…
ital0 e273bd6
test(THU-601): update the connect-dialog clipboard assertion to the p…
ital0 8574550
fix(THU-601): make the zeus-release job permission a static literal (…
ital0 7a4d491
chore(THU-601): drop the rename design doc from the PR
ital0 5f83113
refactor(THU-601): migrate the zeus CLI package from JavaScript to Ty…
ital0 1930e90
docs(THU-601): correct the ZEUS_INSTALL_CMD comment about the dialog …
ital0 ec043dc
fix(THU-601): warn on any non-loopback bind, not only with --allow-an…
ital0 08dd2aa
fix(THU-601): replace URL.canParse with try/new URL for Node <18.17 a…
ital0 26a8a01
fix(THU-601): guard the child error handler so spawn-error and exit c…
ital0 4e8ffee
fix(THU-601): settle the MCP face start once so a late spawn-error ca…
ital0 7b7b678
refactor(THU-601): tighten the zeus TS package to CLAUDE.md (drop dea…
ital0 4a91409
refactor: rename zeus cli to thunderbolt, move package to cli/
ital0 4ab5e38
fix: guard acp face against spawn error after listen
ital0 1a5b1e2
fix: guard mcp face against server error after listen
ital0 46b7fcd
fix: never SIGKILL a pidless child to avoid process-group suicide
ital0 950a0e1
fix: keep signal shutdown exit code from being overridden by child exit
ital0 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| node_modules/ | ||
| dist/ | ||
| *.log |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| <!-- 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 | ||
|
|
||
| `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 <command> [options] | ||
| thunderbolt bridge --mode <acp|mcp> [options] -- <launch> [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 <acp\|mcp>` | _required_ | Which face to stand up. | | ||
| | `--host <h>` | `127.0.0.1` | Bind host. Non-loopback hosts trigger loud warnings. | | ||
| | `--port <n>` | `0` | Bind port. `0` lets the OS assign an ephemeral port. | | ||
| | `--allow-origin <o>` | — | 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. |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.