Skip to content
Closed
Show file tree
Hide file tree
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 Jun 25, 2026
eca4e20
ci(THU-601): build, smoke-test and release stdio-bridge on tag
ital0 Jun 25, 2026
1fead79
feat(THU-601): connect local ACP/MCP agents via stdio-bridge from the…
ital0 Jun 25, 2026
43e3f3a
refactor(THU-601): share bridge teardown latch and frame classifier, …
ital0 Jun 25, 2026
204d3fb
fix(THU-601): tunnel real bound port, reject disallowed MCP origins, …
ital0 Jun 25, 2026
91324ac
fix(THU-601): run installed bridge directly, scope app origin, verify…
ital0 Jun 25, 2026
472ecd6
fix(THU-601): allow Mcp-Protocol-Version in MCP CORS preflight
ital0 Jun 25, 2026
f02f09f
fix(THU-601): drop duplicate shebang so the bundled bridge.cjs runs
ital0 Jun 25, 2026
8af4c9e
chore: gitignore .playwright-mcp scratch snapshots
ital0 Jun 25, 2026
3b2b80d
fix(THU-601): multiplex MCP sessions so concurrent clients share one …
ital0 Jun 25, 2026
443d906
docs(zeus): plan rename of thunderbolt-stdio-bridge to zeus
ital0 Jun 25, 2026
d961a22
refactor(THU-601): rename bridge CLI package to zeus with a bridge su…
ital0 Jun 25, 2026
d359c75
refactor(THU-601): point app connect flow and in-app tutorial at zeus…
ital0 Jun 25, 2026
677c4ee
ci(THU-601): rename bridge release workflow to zeus-release
ital0 Jun 25, 2026
db879ab
refactor(THU-601): dispatch zeus subcommands by name so future comman…
ital0 Jun 25, 2026
dd26c7e
fix(THU-601): pass child --help/--version past the -- delimiter to th…
ital0 Jun 25, 2026
5ccfa73
chore(THU-601): rename ZEUS_BIN_DIR, sync lockfile name, guard subcom…
ital0 Jun 25, 2026
fd51670
feat(THU-601): add a connect-local-MCP-server-via-bridge flow to the …
ital0 Jun 25, 2026
892b9e4
feat(THU-601): add desktop auto-install button to the bridge connect …
ital0 Jun 25, 2026
7fe7ff5
refactor(THU-601): extract shared bridge Step and link install-comman…
ital0 Jun 25, 2026
f2fac2e
fix(THU-601): gate bridge auto-install to unix desktop and run via th…
ital0 Jun 25, 2026
7e3ba5f
fix(THU-601): set pipefail so a failed installer download isn't a fal…
ital0 Jun 25, 2026
874596e
fix(THU-601): drop unused mcp-server imports and annotate the allowli…
ital0 Jun 25, 2026
a13008c
fix(THU-601): classify the whole 127.0.0.0/8 block as loopback to mat…
ital0 Jun 25, 2026
86037d5
fix(THU-601): hide the loopback hint when the URL is rejected on the …
ital0 Jun 25, 2026
8acba78
chore(THU-601): place the CORS nosemgrep on the matched line so the s…
ital0 Jun 26, 2026
5cd2e1a
chore(THU-601): use a bare nosemgrep on the allowlisted-CORS reflecti…
ital0 Jun 26, 2026
a7013af
fix(THU-601): run the auto-install pipefail under bash so non-bash lo…
ital0 Jun 26, 2026
9b25b5c
fix(THU-601): make the zeus release publish step idempotent on re-runs
ital0 Jun 26, 2026
22195fc
fix(THU-601): wrap the manual install command in pipefail so a failed…
ital0 Jun 26, 2026
e273bd6
test(THU-601): update the connect-dialog clipboard assertion to the p…
ital0 Jun 26, 2026
8574550
fix(THU-601): make the zeus-release job permission a static literal (…
ital0 Jun 26, 2026
7a4d491
chore(THU-601): drop the rename design doc from the PR
ital0 Jun 26, 2026
5f83113
refactor(THU-601): migrate the zeus CLI package from JavaScript to Ty…
ital0 Jun 26, 2026
1930e90
docs(THU-601): correct the ZEUS_INSTALL_CMD comment about the dialog …
ital0 Jun 26, 2026
ec043dc
fix(THU-601): warn on any non-loopback bind, not only with --allow-an…
ital0 Jun 26, 2026
08dd2aa
fix(THU-601): replace URL.canParse with try/new URL for Node <18.17 a…
ital0 Jun 26, 2026
26a8a01
fix(THU-601): guard the child error handler so spawn-error and exit c…
ital0 Jun 26, 2026
4e8ffee
fix(THU-601): settle the MCP face start once so a late spawn-error ca…
ital0 Jun 26, 2026
7b7b678
refactor(THU-601): tighten the zeus TS package to CLAUDE.md (drop dea…
ital0 Jun 26, 2026
4a91409
refactor: rename zeus cli to thunderbolt, move package to cli/
ital0 Jun 29, 2026
4ab5e38
fix: guard acp face against spawn error after listen
ital0 Jun 29, 2026
1a5b1e2
fix: guard mcp face against server error after listen
ital0 Jun 29, 2026
46b7fcd
fix: never SIGKILL a pidless child to avoid process-group suicide
ital0 Jun 29, 2026
950a0e1
fix: keep signal shutdown exit code from being overridden by child exit
ital0 Jun 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions .github/workflows/thunderbolt-release.yml
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:
Comment thread
ital0 marked this conversation as resolved.
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Playwright MCP scratch (accessibility-tree snapshots can capture typed secrets)
.playwright-mcp/

# Logs
logs
*.log
Expand Down
3 changes: 3 additions & 0 deletions cli/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
dist/
*.log
121 changes: 121 additions & 0 deletions cli/README.md
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.
Loading
Loading