tunnel-mcp 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -3
- package/README.md +69 -47
- package/SECURITY.md +71 -52
- package/dist/config.d.ts +2 -0
- package/dist/config.js +3 -0
- package/dist/invite.d.ts +11 -0
- package/dist/invite.js +19 -0
- package/dist/log/sessionLog.d.ts +9 -3
- package/dist/log/sessionLog.js +19 -32
- package/dist/protocol/crypto.d.ts +1 -0
- package/dist/protocol/crypto.js +5 -0
- package/dist/protocol/link.d.ts +2 -1
- package/dist/protocol/link.js +11 -4
- package/dist/protocol/messages.d.ts +20 -6
- package/dist/protocol/messages.js +3 -0
- package/dist/relay/hostRelay.d.ts +23 -13
- package/dist/relay/hostRelay.js +158 -127
- package/dist/relay/inviteLedger.d.ts +17 -0
- package/dist/relay/inviteLedger.js +38 -0
- package/dist/relay/{guestClient.d.ts → memberClient.d.ts} +9 -5
- package/dist/relay/{guestClient.js → memberClient.js} +24 -15
- package/dist/session.d.ts +31 -9
- package/dist/session.js +85 -33
- package/dist/tools.js +15 -8
- package/package.json +2 -1
- package/skill/tunnel-etiquette/SKILL.md +19 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,11 +5,57 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [0.2.0] - 2026-07-02
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
11
|
|
|
12
|
-
-
|
|
12
|
+
- **Invite-only rooms, up to 16 participants (host included).** `tunnel_open`
|
|
13
|
+
now accepts `invites` (default 1, the classic two-party tunnel) and mints
|
|
14
|
+
one single-use, expiring invite per expected teammate. The new host-only
|
|
15
|
+
`tunnel_invite` tool mints more invites mid-session — to add a teammate or
|
|
16
|
+
re-admit someone who disconnected (their old invite stays dead; a
|
|
17
|
+
disconnected member never reuses it, only a fresh one lets them back in).
|
|
18
|
+
Every session now tracks a **roster**: `tunnel_join` and `tunnel_status`
|
|
19
|
+
return the current members (name/host-flag/connected), and every message
|
|
20
|
+
`tunnel_listen` returns carries `fromName` resolved from that roster, so
|
|
21
|
+
agents can address each other by name instead of "the peer."
|
|
22
|
+
|
|
23
|
+
### Breaking
|
|
24
|
+
|
|
25
|
+
Protocol v2, incompatible with v1 in both directions:
|
|
26
|
+
|
|
27
|
+
- **A v2 client given an old v1 (tokenless) link** gets an explicit
|
|
28
|
+
upgrade-and-retry message naming the fix — the modern client recognizes the
|
|
29
|
+
old link and tells you what to do. If the host is genuinely still on the old
|
|
30
|
+
code and can't upgrade, you can join their v1 tunnel with the old client via
|
|
31
|
+
`npx -y tunnel-mcp@0.1`.
|
|
32
|
+
- **An old v1 client given a v2 link (or dialing a v2 host)** cannot connect:
|
|
33
|
+
the v1 client fails locally with a generic error (its parser rejects the
|
|
34
|
+
`#key.token` fragment as an invalid key), and a v1 client that does reach a
|
|
35
|
+
v2 host is turned away as incompatible. The remedy here is to **upgrade** that
|
|
36
|
+
client (`npx -y tunnel-mcp@latest`), not downgrade — a v2 room cannot admit a
|
|
37
|
+
v1 client.
|
|
38
|
+
|
|
39
|
+
Migration for anything calling the library API directly (MCP tool callers are
|
|
40
|
+
unaffected beyond the richer response shapes):
|
|
41
|
+
|
|
42
|
+
| API | v1 (0.1.x) | v2 (this change) |
|
|
43
|
+
| ------------------ | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
44
|
+
| `session.open()` | `{ tunnelId, joinLink, status: 'waiting_for_guest', joinLinkExpiresInSec, invite }` | `{ tunnelId, status: 'waiting_for_members', invites: [{ joinLink, invite, expiresInSec }, ...] }`, plus top-level `joinLink` / `invite` / `joinLinkExpiresInSec` **only** when `invites.length === 1` (the default two-party continuity trio) |
|
|
45
|
+
| `session.join()` | `{ tunnelId, goal, peer }` | `{ tunnelId, goal, self: { id, name }, members: RosterEntry[] }` |
|
|
46
|
+
| `session.status()` | `{ role, peerConnected, goal, lastSeq, openedAt }` | `{ role, goal, lastSeq, openedAt, members: { name, isHost, connected }[], pendingInvites }` — **`peerConnected` removed**, use `members[].connected` instead |
|
|
47
|
+
| Wire protocol | v1 links/frames accepted | v1 and v2 are mutually incompatible. A v2 client on a v1 link throws an upgrade-and-retry error (escape hatch: `npx -y tunnel-mcp@0.1`). A v1 client on a v2 link/host cannot connect — it fails with a generic local error or is refused by the host as incompatible, and must upgrade (`npx -y tunnel-mcp@latest`). |
|
|
48
|
+
| `Role` type | exported from `protocol/messages.ts` (`'host' \| 'guest'`) | **removed** — replaced by the opaque `ParticipantId` and the `RosterEntry` roster shape |
|
|
49
|
+
|
|
50
|
+
## [0.1.9] - 2026-07-02
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- **`tunnel_open` now returns `invite`** — a ready-to-forward plain-text message
|
|
55
|
+
containing the one-time setup command (`claude mcp add tunnel -- npx -y
|
|
56
|
+
tunnel-mcp`) and the join link, so the host's human can paste one message to
|
|
57
|
+
the other developer instead of explaining the install. The tool description
|
|
58
|
+
directs the agent to relay it verbatim.
|
|
13
59
|
|
|
14
60
|
## [0.1.8] - 2026-07-02
|
|
15
61
|
|
|
@@ -171,7 +217,8 @@ install-skill` copies the `tunnel-etiquette` skill into `~/.claude/skills`
|
|
|
171
217
|
declaring a fix "confirmed".
|
|
172
218
|
- Test suite of 109 tests built with vitest, developed test-first (TDD).
|
|
173
219
|
|
|
174
|
-
[Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.
|
|
220
|
+
[Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.9...HEAD
|
|
221
|
+
[0.1.9]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.8...v0.1.9
|
|
175
222
|
[0.1.8]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.7...v0.1.8
|
|
176
223
|
[0.1.7]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.6...v0.1.7
|
|
177
224
|
[0.1.6]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.5...v0.1.6
|
package/README.md
CHANGED
|
@@ -49,8 +49,9 @@ firewalls and NAT.
|
|
|
49
49
|
└───────────────────┘
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
The relay
|
|
53
|
-
|
|
52
|
+
The relay and the `cloudflared` child process live only for the lifetime of the
|
|
53
|
+
session and are destroyed on teardown. The transcript is held in memory only —
|
|
54
|
+
nothing is ever written to disk, and it vanishes with the process at teardown.
|
|
54
55
|
|
|
55
56
|
## Install
|
|
56
57
|
|
|
@@ -96,70 +97,92 @@ it isn't already on your `PATH` — there's nothing extra to install.
|
|
|
96
97
|
|
|
97
98
|
> "Open a tunnel to pair on debugging the checkout flow."
|
|
98
99
|
|
|
99
|
-
Claude calls `tunnel_open({ goal })` and
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
**
|
|
103
|
-
|
|
100
|
+
Claude calls `tunnel_open({ goal })` and hands back a ready-to-forward
|
|
101
|
+
**invite** — one plain-text message containing the one-time setup command and
|
|
102
|
+
the join link. Paste it to the other developer over a trusted channel (Slack
|
|
103
|
+
DM, etc.) — **the link is a secret**, since it contains the encryption key for
|
|
104
|
+
the session. It is **single-use and expires after ~10 minutes**
|
|
105
|
+
(`tunnel_open` reports `joinLinkExpiresInSec`), so share it promptly.
|
|
104
106
|
|
|
105
107
|
**Guest** — paste the link and ask Claude to join:
|
|
106
108
|
|
|
107
109
|
> "Join this tunnel: `<link>`"
|
|
108
110
|
|
|
109
|
-
Claude calls `tunnel_join({ joinLink })`, learns the goal, and
|
|
110
|
-
|
|
111
|
+
Claude calls `tunnel_join({ joinLink })`, learns the goal, and gets back the
|
|
112
|
+
room's member roster — with the default single invite, that's just the two of
|
|
113
|
+
you.
|
|
114
|
+
|
|
115
|
+
**More than one guest? Open a room instead:**
|
|
116
|
+
|
|
117
|
+
> "Open a tunnel for me and two teammates, to pair on the checkout flow."
|
|
118
|
+
|
|
119
|
+
Claude calls `tunnel_open({ goal, invites: 3 })` — `invites` is the number of
|
|
120
|
+
teammates to seat (up to 15, plus the host makes 16 connected at once) — and gets
|
|
121
|
+
back one **invite** per teammate instead of a single link. Forward each invite
|
|
122
|
+
to exactly one person; every invite is single-use, so don't reuse one link for
|
|
123
|
+
two people. Need to add someone mid-session, or re-admit someone whose invite
|
|
124
|
+
expired before they used it? `tunnel_invite({ count })` (host-only) mints more.
|
|
111
125
|
|
|
112
126
|
**Both** — the agents converse turn-by-turn using `tunnel_say` to send and
|
|
113
|
-
`tunnel_listen` to wait for the next reply
|
|
114
|
-
|
|
127
|
+
`tunnel_listen` to wait for the next reply. In a room, every message arrives
|
|
128
|
+
with `fromName` so agents can tell who said what, checking in with their humans
|
|
129
|
+
as needed.
|
|
115
130
|
|
|
116
|
-
**
|
|
117
|
-
and
|
|
131
|
+
**Ending it** is role-sensitive: the **host** calls `tunnel_close` to end the
|
|
132
|
+
session for everyone and tear down the relay — the in-memory transcript vanishes
|
|
133
|
+
with it, since it was never written to disk. A **member** calling `tunnel_close`
|
|
134
|
+
just leaves; the room stays open for whoever's left.
|
|
118
135
|
|
|
119
136
|
## Tools
|
|
120
137
|
|
|
121
|
-
| Tool | Who
|
|
122
|
-
| ---------------------------------------- |
|
|
123
|
-
| `tunnel_open({goal})`
|
|
124
|
-
| `
|
|
125
|
-
| `
|
|
126
|
-
| `
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
138
|
+
| Tool | Who | Purpose |
|
|
139
|
+
| ---------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------- |
|
|
140
|
+
| `tunnel_open({goal, invites?})` | host | Start the relay + Quick Tunnel and get back one invite per teammate (default 1 — classic two-party). |
|
|
141
|
+
| `tunnel_invite({count?})` | host | Mint more single-use, expiring invites mid-session. |
|
|
142
|
+
| `tunnel_join({joinLink})` | member | Dial into a room using an invite link and authenticate; returns the current member roster. |
|
|
143
|
+
| `tunnel_say({text})` | any | Send a message to the room. |
|
|
144
|
+
| `tunnel_listen({sinceSeq?, timeoutMs?})` | any | Wait for the next message(s), each tagged with the sender's `fromName`. |
|
|
145
|
+
| `tunnel_status()` | any | Inspect the session: role, goal, member roster, pending invites, lastSeq. |
|
|
146
|
+
| `tunnel_close({summary?})` | any | Host: ends the session for everyone. Member: leaves the room. |
|
|
129
147
|
|
|
130
148
|
## Security model
|
|
131
149
|
|
|
132
150
|
tunnel-mcp is a security-sensitive tool by nature — it opens a live channel
|
|
133
|
-
between
|
|
151
|
+
between developers' AI agents. Here's exactly what it does and does not protect:
|
|
134
152
|
|
|
135
153
|
- **Chat message bodies are end-to-end encrypted.** Every `tunnel_say` body is
|
|
136
154
|
sealed with NaCl `secretbox` (XSalsa20-Poly1305, via `tweetnacl`) before it
|
|
137
155
|
crosses the `cloudflared` pipe. The relay and the pipe only ever see
|
|
138
156
|
ciphertext for chat bodies.
|
|
139
|
-
- **The goal,
|
|
140
|
-
`tunnel_open` goal, each
|
|
141
|
-
(joined/left/idle/closed) are sent as plaintext metadata — do not put
|
|
142
|
-
in the goal string or a display name.
|
|
157
|
+
- **The goal, every participant's display name, and system events are
|
|
158
|
+
plaintext.** The `tunnel_open` goal, each member's name, and connection
|
|
159
|
+
events (joined/left/idle/closed) are sent as plaintext metadata — do not put
|
|
160
|
+
secrets in the goal string or a display name.
|
|
143
161
|
- **Authentication is proof-of-key-possession, not key transmission.** Joining
|
|
144
|
-
uses an HMAC challenge to prove the
|
|
145
|
-
raw key itself is never sent over the wire.
|
|
146
|
-
- **
|
|
162
|
+
uses an HMAC challenge to prove the joining member holds the same key as the
|
|
163
|
+
host; the raw key itself is never sent over the wire.
|
|
164
|
+
- **Each invite is a single-use, expiring credential.** It embeds the session
|
|
147
165
|
key, so treat it like a password — share it only over a channel you already
|
|
148
|
-
trust (Slack DM, etc.), never in a public issue, PR, or chat
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
window of exposure.
|
|
152
|
-
- **
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
166
|
+
trust (Slack DM, etc.), never in a public issue, PR, or chat, and forward each
|
|
167
|
+
invite to exactly one person. It is consumed by whoever redeems it first (and
|
|
168
|
+
can't be reused, even after they leave) and expires on its own after ~10
|
|
169
|
+
minutes, so a leaked invite has a short, bounded window of exposure.
|
|
170
|
+
- **Admits exactly whom you invited** — two-party by default, rooms opt-in
|
|
171
|
+
(cap 16), every invite single-use + expiring. Admission is bounded by how
|
|
172
|
+
many invites the host chose to mint, not by who happens to have the room's
|
|
173
|
+
key.
|
|
174
|
+
- **The peer is untrusted input, not an instruction source.** Messages from
|
|
175
|
+
other agents are data to reason about, not commands to execute — and this
|
|
176
|
+
applies to every member in a room, not just one. The etiquette skill directs
|
|
177
|
+
each agent to require its own human's sign-off before writing files, running
|
|
178
|
+
risky commands, or declaring a fix "confirmed" based on something a peer
|
|
179
|
+
said.
|
|
180
|
+
- **Everything is ephemeral.** The transcript is held in memory only — nothing
|
|
181
|
+
is ever written to disk, and it vanishes with the process. Teardown is
|
|
182
|
+
role-sensitive: the host's `tunnel_close` (or their process exiting, or 30
|
|
183
|
+
minutes of no messages) ends the session for everyone and tears down the
|
|
184
|
+
relay + `cloudflared` child process; a member's `tunnel_close` just leaves —
|
|
185
|
+
the room stays open for whoever's left.
|
|
163
186
|
|
|
164
187
|
See [SECURITY.md](./SECURITY.md) for the full threat model and how to report a
|
|
165
188
|
vulnerability.
|
|
@@ -175,7 +198,7 @@ vulnerability.
|
|
|
175
198
|
|
|
176
199
|
```bash
|
|
177
200
|
npm ci # install dependencies
|
|
178
|
-
npm test # run the test suite (
|
|
201
|
+
npm test # run the test suite (198 tests, TDD)
|
|
179
202
|
npm run build # compile TypeScript
|
|
180
203
|
npm run lint # eslint
|
|
181
204
|
npm run format:check # prettier --check .
|
|
@@ -209,9 +232,8 @@ system DNS already resolves `*.trycloudflare.com`.
|
|
|
209
232
|
This is an MVP. The following are explicitly out of scope for now:
|
|
210
233
|
|
|
211
234
|
- Host-offline / asynchronous messaging
|
|
212
|
-
- More than two participants in a session
|
|
213
235
|
- Alternative transports (ngrok, WebRTC)
|
|
214
|
-
-
|
|
236
|
+
- Invite rotation (replacing a specific still-valid invite mid-session; note invites are already single-use and expiring — see the security model above)
|
|
215
237
|
- Encrypting the goal or other metadata
|
|
216
238
|
|
|
217
239
|
## License
|
package/SECURITY.md
CHANGED
|
@@ -41,10 +41,11 @@ When you report, please include:
|
|
|
41
41
|
|
|
42
42
|
## Security Model
|
|
43
43
|
|
|
44
|
-
tunnel-mcp lets
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
tunnel-mcp lets developers' Claude agents exchange messages directly through a
|
|
45
|
+
host-owned, ephemeral relay, without a human copy-pasting between them.
|
|
46
|
+
Two-party is the default; the host can opt into a room of up to 16
|
|
47
|
+
participants. Understanding what is and isn't protected is important before
|
|
48
|
+
you share an invite with anyone.
|
|
48
49
|
|
|
49
50
|
- **Chat message bodies are end-to-end encrypted.** The text passed to
|
|
50
51
|
`tunnel_say` is sealed with NaCl `secretbox` (XSalsa20-Poly1305, via
|
|
@@ -52,36 +53,46 @@ share a join link with anyone.
|
|
|
52
53
|
pipe — and the Cloudflare edge it runs over — only ever sees ciphertext
|
|
53
54
|
for chat message bodies.
|
|
54
55
|
- **Metadata is plaintext.** The `goal` passed to `tunnel_open`/`tunnel_join`,
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
- **Authentication is proof-of-key-possession, not key transmission.**
|
|
59
|
-
|
|
60
|
-
key via an HMAC challenge/response; the raw key itself
|
|
61
|
-
the wire. Because
|
|
62
|
-
like a password** — share it only over a trusted,
|
|
63
|
-
channel (e.g. a Slack DM to a known teammate), not in
|
|
64
|
-
ticket.
|
|
65
|
-
- **
|
|
66
|
-
|
|
67
|
-
even after that
|
|
68
|
-
by default), so
|
|
69
|
-
damage from a leaked
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
- **
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
`
|
|
84
|
-
|
|
56
|
+
every member's display name, and system/connection events (joined, left,
|
|
57
|
+
idle, closed) cross the tunnel as **plaintext**. Do not put secrets in the
|
|
58
|
+
goal or a display name.
|
|
59
|
+
- **Authentication is proof-of-key-possession, not key transmission.** Every
|
|
60
|
+
invite for a session embeds the same session key. A joining member's client
|
|
61
|
+
proves it holds that key via an HMAC challenge/response; the raw key itself
|
|
62
|
+
is never sent over the wire. Because an invite contains the key, **treat
|
|
63
|
+
every invite like a password** — share it only over a trusted,
|
|
64
|
+
already-authenticated channel (e.g. a Slack DM to a known teammate), not in
|
|
65
|
+
a public channel or ticket, and forward each invite to exactly one person.
|
|
66
|
+
- **Invites are single-use and expiring.** Each invite is consumed by whoever
|
|
67
|
+
successfully authenticates with it first and can never be redeemed again —
|
|
68
|
+
even after that person disconnects. Invites also expire on their own (10
|
|
69
|
+
minutes by default), so one that's never used stops working. This bounds
|
|
70
|
+
the damage from a leaked invite to a short window before it is used or
|
|
71
|
+
expires.
|
|
72
|
+
- **Invite-ledger admission.** A session admits only people the host minted
|
|
73
|
+
an invite for — up to 16 members connected at once, including the host.
|
|
74
|
+
Two-party remains the default. Every invite is single-use, consumed
|
|
75
|
+
atomically by the first successful join and dead forever after — the same
|
|
76
|
+
invite can never be redeemed a second time, even by the person who first
|
|
77
|
+
used it. Invites expire after ~10 minutes. A disconnected member's old
|
|
78
|
+
invite stays dead; getting back in requires a fresh invite from the host.
|
|
79
|
+
Only the host can mint invites, so a member who leaks the room key alone
|
|
80
|
+
cannot seat anyone.
|
|
81
|
+
- **Peer input is untrusted, from every member.** Everything a peer sends
|
|
82
|
+
over the tunnel is data, never an instruction — and in a room, that holds
|
|
83
|
+
for each participant individually, not just "the other side." The bundled
|
|
84
|
+
`tunnel-etiquette` skill instructs each agent to treat incoming peer
|
|
85
|
+
messages as untrusted input and to get its own human's explicit OK before
|
|
86
|
+
writing files, running risky commands, or declaring a fix "confirmed"
|
|
87
|
+
based on something a peer said.
|
|
88
|
+
- **Ephemeral by design.** The transcript is held in memory only — it is
|
|
89
|
+
never written to disk, and it vanishes with the process. Teardown is
|
|
90
|
+
role-sensitive: the host's explicit `tunnel_close`, an idle timeout (30
|
|
91
|
+
minutes with no messages), or the host process exiting all tear down the
|
|
92
|
+
whole session — the in-process relay, the cloudflared child process, and
|
|
93
|
+
the throwaway Quick Tunnel URL — for every member at once. A member's own
|
|
94
|
+
`tunnel_close` only removes that member; it does not tear anything down
|
|
95
|
+
for anyone else. Nothing persists past a session's own teardown.
|
|
85
96
|
|
|
86
97
|
## Supply chain
|
|
87
98
|
|
|
@@ -120,25 +131,32 @@ protect against:
|
|
|
120
131
|
|
|
121
132
|
- **The relay path sees metadata in the clear.** The cloudflared Quick
|
|
122
133
|
Tunnel is a real network hop through Cloudflare's edge. While chat
|
|
123
|
-
message bodies are encrypted end-to-end, the goal,
|
|
134
|
+
message bodies are encrypted end-to-end, the goal, every display name,
|
|
124
135
|
and system/connection events are visible in plaintext to anything that
|
|
125
136
|
can observe that path (including Cloudflare's infrastructure). Do not
|
|
126
137
|
put secrets in the goal or names.
|
|
127
|
-
- **A leaked
|
|
128
|
-
joins.**
|
|
129
|
-
leaked
|
|
130
|
-
longer admit anyone. The residual risk is a race: if
|
|
131
|
-
attacker redeems it faster than your intended
|
|
132
|
-
window and before that
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
138
|
+
- **A leaked invite can still be redeemed within its window, before your
|
|
139
|
+
intended teammate joins.** Invites are single-use and expire (10 minutes
|
|
140
|
+
by default), so a leaked invite that is never used, has already been used,
|
|
141
|
+
or has aged out can no longer admit anyone. The residual risk is a race: if
|
|
142
|
+
an invite leaks and an attacker redeems it faster than your intended
|
|
143
|
+
teammate — within the expiry window and before that person connects — the
|
|
144
|
+
attacker consumes the single-use invite, joins in their place, and locks
|
|
145
|
+
them out (a fresh invite from the host is needed to re-admit them). Share
|
|
146
|
+
invites only over trusted channels, one per person, and mint a fresh
|
|
147
|
+
invite (or open a fresh tunnel) if you suspect one was exposed before it
|
|
148
|
+
was used. There is no in-session key rotation.
|
|
136
149
|
- **The goal is never encrypted.** By design, the goal string is plaintext
|
|
137
150
|
metadata used for connection setup and display; it receives no
|
|
138
151
|
confidentiality protection at any layer.
|
|
139
|
-
- **
|
|
140
|
-
|
|
141
|
-
|
|
152
|
+
- **One shared key per room — every member reads everything.** All chat
|
|
153
|
+
message bodies in a session are encrypted under the single key embedded in
|
|
154
|
+
every invite for that session, not a separate key per pair of
|
|
155
|
+
participants. In a room, that means every current member can decrypt every
|
|
156
|
+
other member's chat messages — there is no sub-group or pairwise privacy
|
|
157
|
+
within a session. Admission is still gated per-person by the invite ledger
|
|
158
|
+
(see above); this limitation is about message confidentiality once someone
|
|
159
|
+
is in the room, not about who can get in.
|
|
142
160
|
- **"Trusting" a peer only goes as far as your own agent's guardrails.**
|
|
143
161
|
tunnel-mcp does not sandbox or validate what a peer sends beyond
|
|
144
162
|
transport-level auth. The confidentiality/integrity of your own
|
|
@@ -147,12 +165,13 @@ protect against:
|
|
|
147
165
|
requiring human approval for file writes, running commands, or
|
|
148
166
|
confirming fixes). If you disable or bypass that skill, a malicious or
|
|
149
167
|
compromised peer's messages could otherwise be misinterpreted as
|
|
150
|
-
instructions by an unguarded agent
|
|
168
|
+
instructions by an unguarded agent — and in a room, this applies to
|
|
169
|
+
every member, not just one.
|
|
151
170
|
- **Out of scope for this release**: host-offline/async messaging,
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
connection metadata. These may be
|
|
155
|
-
should not be assumed to exist today.
|
|
171
|
+
alternative transports (ngrok, WebRTC), in-session key/invite rotation
|
|
172
|
+
(replacing a still-valid invite before it's used or expires), and
|
|
173
|
+
encryption of the goal or other connection metadata. These may be
|
|
174
|
+
considered for future versions but should not be assumed to exist today.
|
|
156
175
|
|
|
157
176
|
If you find a way to break any of the guarantees above (e.g. read a chat
|
|
158
177
|
message body without the key, join a locked session, or get an agent to
|
package/dist/config.d.ts
CHANGED
|
@@ -17,3 +17,5 @@ export declare const GUEST_CONNECT_DEADLINE_MS = 20000;
|
|
|
17
17
|
export declare const GUEST_SYS_LOOKUP_TIMEOUT_MS = 2000;
|
|
18
18
|
export declare const DOH_GUEST_RETRIES = 3;
|
|
19
19
|
export declare const DOH_GUEST_RETRY_DELAY_MS = 700;
|
|
20
|
+
export declare const MAX_ROOM_MEMBERS = 16;
|
|
21
|
+
export declare const PROTOCOL_VERSION = 2;
|
package/dist/config.js
CHANGED
|
@@ -46,3 +46,6 @@ export const GUEST_CONNECT_DEADLINE_MS = 20_000; // overall connect+auth deadlin
|
|
|
46
46
|
export const GUEST_SYS_LOOKUP_TIMEOUT_MS = 2_000; // bound the system-first lookup before DoH fallback
|
|
47
47
|
export const DOH_GUEST_RETRIES = 3; // DoH attempts in the guest fallback
|
|
48
48
|
export const DOH_GUEST_RETRY_DELAY_MS = 700; // backoff between guest DoH attempts
|
|
49
|
+
// Rooms
|
|
50
|
+
export const MAX_ROOM_MEMBERS = 16; // includes the host → at most 15 ws members
|
|
51
|
+
export const PROTOCOL_VERSION = 2;
|
package/dist/invite.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ready-to-forward invite a host's human sends to the other developer.
|
|
3
|
+
* Every tunnel needs a second dev with tunnel-mcp installed — so the invite
|
|
4
|
+
* carries the one-time setup command alongside the join link, making each
|
|
5
|
+
* session recruit its own second participant with zero friction.
|
|
6
|
+
*/
|
|
7
|
+
export declare function buildInvite(opts: {
|
|
8
|
+
goal: string;
|
|
9
|
+
joinLink: string;
|
|
10
|
+
expiresInSec: number;
|
|
11
|
+
}): string;
|
package/dist/invite.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ready-to-forward invite a host's human sends to the other developer.
|
|
3
|
+
* Every tunnel needs a second dev with tunnel-mcp installed — so the invite
|
|
4
|
+
* carries the one-time setup command alongside the join link, making each
|
|
5
|
+
* session recruit its own second participant with zero friction.
|
|
6
|
+
*/
|
|
7
|
+
export function buildInvite(opts) {
|
|
8
|
+
const mins = Math.max(1, Math.ceil(opts.expiresInSec / 60));
|
|
9
|
+
return [
|
|
10
|
+
`You're invited to a Claude-agent tunnel — goal: "${opts.goal}"`,
|
|
11
|
+
``,
|
|
12
|
+
`1) One-time setup (skip if you already have tunnel-mcp):`,
|
|
13
|
+
` claude mcp add tunnel -- npx -y tunnel-mcp`,
|
|
14
|
+
`2) Then tell your Claude:`,
|
|
15
|
+
` Join this tunnel: ${opts.joinLink}`,
|
|
16
|
+
``,
|
|
17
|
+
`The link is single-use and expires in ~${mins} minute${mins === 1 ? '' : 's'}.`,
|
|
18
|
+
].join('\n');
|
|
19
|
+
}
|
package/dist/log/sessionLog.d.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { WireMessage } from '../protocol/messages.js';
|
|
2
|
+
/**
|
|
3
|
+
* In-memory-only transcript for a single tunnel session. There is no
|
|
4
|
+
* disk-writing path here — the security docs promise the transcript never
|
|
5
|
+
* touches disk, and that is structural: no file handle, no fs import, no
|
|
6
|
+
* append(). See the 0.2.0 final-review wave for the removal of the old
|
|
7
|
+
* fs.appendFileSync-backed append() method.
|
|
8
|
+
*/
|
|
2
9
|
export declare class SessionLog {
|
|
3
10
|
private msgs;
|
|
4
11
|
private seqCounter;
|
|
5
|
-
private filePath;
|
|
6
12
|
private closed;
|
|
7
|
-
constructor(
|
|
8
|
-
append(msg: WireMessage): WireMessage;
|
|
13
|
+
constructor(_tunnelId: string);
|
|
9
14
|
record(finalized: WireMessage): void;
|
|
10
15
|
since(sinceSeq: number): WireMessage[];
|
|
11
16
|
all(): WireMessage[];
|
|
12
17
|
get lastSeq(): number;
|
|
18
|
+
/** Clears in-memory state. Safe no-op if already cleared. */
|
|
13
19
|
delete(): void;
|
|
14
20
|
}
|
package/dist/log/sessionLog.js
CHANGED
|
@@ -1,37 +1,29 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* In-memory-only transcript for a single tunnel session. There is no
|
|
3
|
+
* disk-writing path here — the security docs promise the transcript never
|
|
4
|
+
* touches disk, and that is structural: no file handle, no fs import, no
|
|
5
|
+
* append(). See the 0.2.0 final-review wave for the removal of the old
|
|
6
|
+
* fs.appendFileSync-backed append() method.
|
|
7
|
+
*/
|
|
4
8
|
export class SessionLog {
|
|
5
9
|
msgs = [];
|
|
6
10
|
seqCounter = 0;
|
|
7
|
-
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// resurrect the .jsonl file moments after delete() removed it, leaving an
|
|
12
|
-
// orphaned log on disk forever.
|
|
11
|
+
// Set once delete() has run. After that point record() must be a no-op
|
|
12
|
+
// with respect to the in-memory store — otherwise a late event (e.g. a
|
|
13
|
+
// guest socket's 'close' handler firing after teardown) can resurrect
|
|
14
|
+
// state moments after delete() cleared it.
|
|
13
15
|
closed = false;
|
|
14
|
-
constructor(
|
|
15
|
-
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
16
|
-
this.filePath = path.join(SESSIONS_DIR, `${tunnelId}.jsonl`);
|
|
17
|
-
}
|
|
18
|
-
append(msg) {
|
|
19
|
-
if (this.closed) {
|
|
20
|
-
// True no-op: stub a finalized-looking message for the caller without
|
|
21
|
-
// advancing seqCounter, touching msgs, or writing to disk — so a late
|
|
22
|
-
// call after delete() can never recreate the file or extend the log.
|
|
23
|
-
return { ...msg, seq: this.seqCounter, ts: Date.now() };
|
|
24
|
-
}
|
|
25
|
-
const finalized = { ...msg, seq: ++this.seqCounter, ts: Date.now() };
|
|
26
|
-
this.msgs.push(finalized);
|
|
27
|
-
fs.appendFileSync(this.filePath, JSON.stringify(finalized) + '\n');
|
|
28
|
-
return finalized;
|
|
29
|
-
}
|
|
16
|
+
constructor(_tunnelId) { }
|
|
30
17
|
record(finalized) {
|
|
31
18
|
if (this.closed)
|
|
32
19
|
return;
|
|
20
|
+
// seq comes from an untrusted host over the wire; a non-finite value
|
|
21
|
+
// must not poison the member-side lastSeq cursor. Keep the message in
|
|
22
|
+
// the transcript, just don't let it advance the cursor.
|
|
33
23
|
this.msgs.push(finalized);
|
|
34
|
-
|
|
24
|
+
if (Number.isFinite(finalized.seq)) {
|
|
25
|
+
this.seqCounter = Math.max(this.seqCounter, finalized.seq);
|
|
26
|
+
}
|
|
35
27
|
}
|
|
36
28
|
since(sinceSeq) {
|
|
37
29
|
return this.msgs.filter((m) => m.seq > sinceSeq);
|
|
@@ -42,13 +34,8 @@ export class SessionLog {
|
|
|
42
34
|
get lastSeq() {
|
|
43
35
|
return this.seqCounter;
|
|
44
36
|
}
|
|
37
|
+
/** Clears in-memory state. Safe no-op if already cleared. */
|
|
45
38
|
delete() {
|
|
46
|
-
try {
|
|
47
|
-
fs.rmSync(this.filePath);
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
/* already gone */
|
|
51
|
-
}
|
|
52
39
|
this.msgs = [];
|
|
53
40
|
this.closed = true;
|
|
54
41
|
}
|
|
@@ -7,3 +7,4 @@ export declare function open(sealed: string, key: Key): string;
|
|
|
7
7
|
export declare function makeChallenge(): string;
|
|
8
8
|
export declare function respondChallenge(challenge: string, key: Key): string;
|
|
9
9
|
export declare function verifyChallenge(challenge: string, response: string, key: Key): boolean;
|
|
10
|
+
export declare function generateToken(): string;
|
package/dist/protocol/crypto.js
CHANGED
|
@@ -37,3 +37,8 @@ export function verifyChallenge(challenge, response, key) {
|
|
|
37
37
|
const got = Buffer.from(response);
|
|
38
38
|
return expected.length === got.length && crypto.timingSafeEqual(expected, got);
|
|
39
39
|
}
|
|
40
|
+
// One-time invite token: 16 random bytes, base64url. High-entropy bearer value
|
|
41
|
+
// redeemed exactly once by the host's invite ledger.
|
|
42
|
+
export function generateToken() {
|
|
43
|
+
return crypto.randomBytes(16).toString('base64url');
|
|
44
|
+
}
|
package/dist/protocol/link.d.ts
CHANGED
|
@@ -2,8 +2,9 @@ import { Key } from './crypto.js';
|
|
|
2
2
|
export interface JoinLink {
|
|
3
3
|
tunnelId: string;
|
|
4
4
|
key: Key;
|
|
5
|
+
token: string;
|
|
5
6
|
wsUrl: string;
|
|
6
7
|
}
|
|
7
8
|
export declare function generateTunnelId(): string;
|
|
8
|
-
export declare function
|
|
9
|
+
export declare function mintInvite(publicBaseUrl: string, tunnelId: string, key: Key, token: string): string;
|
|
9
10
|
export declare function parseLink(link: string): JoinLink;
|
package/dist/protocol/link.js
CHANGED
|
@@ -3,9 +3,9 @@ import { keyToBase64url, keyFromBase64url } from './crypto.js';
|
|
|
3
3
|
export function generateTunnelId() {
|
|
4
4
|
return crypto.randomBytes(8).toString('hex');
|
|
5
5
|
}
|
|
6
|
-
export function
|
|
7
|
-
const wsBase = publicBaseUrl.replace(/^http/, 'ws');
|
|
8
|
-
return `${wsBase}/t/${tunnelId}#${keyToBase64url(key)}`;
|
|
6
|
+
export function mintInvite(publicBaseUrl, tunnelId, key, token) {
|
|
7
|
+
const wsBase = publicBaseUrl.replace(/^http/, 'ws');
|
|
8
|
+
return `${wsBase}/t/${tunnelId}#${keyToBase64url(key)}.${token}`;
|
|
9
9
|
}
|
|
10
10
|
export function parseLink(link) {
|
|
11
11
|
const hashIdx = link.indexOf('#');
|
|
@@ -17,5 +17,12 @@ export function parseLink(link) {
|
|
|
17
17
|
const m = u.pathname.match(/^\/t\/([0-9a-f]+)$/);
|
|
18
18
|
if (!m)
|
|
19
19
|
throw new Error('link missing tunnel id');
|
|
20
|
-
|
|
20
|
+
const parts = keyPart.split('.');
|
|
21
|
+
if (parts.length !== 2 || parts[1].length === 0) {
|
|
22
|
+
if (parts.length === 1) {
|
|
23
|
+
throw new Error('this link is from an older tunnel-mcp host — ask them to upgrade, or join with: npx -y tunnel-mcp@0.1');
|
|
24
|
+
}
|
|
25
|
+
throw new Error('malformed link fragment');
|
|
26
|
+
}
|
|
27
|
+
return { tunnelId: m[1], key: keyFromBase64url(parts[0]), token: parts[1], wsUrl: urlPart };
|
|
21
28
|
}
|