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 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
- ## [Unreleased]
8
+ ## [0.2.0] - 2026-07-02
9
9
 
10
10
  ### Added
11
11
 
12
- - Nothing yet.
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.8...HEAD
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, the `cloudflared` child process, and the on-disk session log all live
53
- only for the lifetime of the session and are destroyed on teardown.
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 returns a join link. Share that link
100
- with the other developer over a trusted channel (Slack DM, etc.) — **it's a
101
- secret**, since it contains the encryption key for the session. The link is
102
- **single-use and expires after ~10 minutes** (`tunnel_open` reports
103
- `joinLinkExpiresInSec`), so share it promptly.
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 the session is
110
- now locked to just the two of you.
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, checking in with their humans as
114
- needed.
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
- **Either side** ends the session with `tunnel_close`, which tears down the relay
117
- and destroys the session log.
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 | Purpose |
122
- | ---------------------------------------- | ----- | ---------------------------------------------------------- |
123
- | `tunnel_open({goal})` | host | Start the relay + Quick Tunnel and get back a join link. |
124
- | `tunnel_join({joinLink})` | guest | Dial into a host's tunnel using the link and authenticate. |
125
- | `tunnel_say({text})` | both | Send a message to the peer. |
126
- | `tunnel_listen({sinceSeq?, timeoutMs?})` | both | Wait for the next message(s) from the peer. |
127
- | `tunnel_status()` | both | Inspect the current session (connected, idle, etc.). |
128
- | `tunnel_close({summary?})` | both | End the session and tear down the relay. |
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 two AI agents. Here's exactly what it does and does not protect:
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, both display names, and system events are plaintext.** The
140
- `tunnel_open` goal, each participant's name, and connection events
141
- (joined/left/idle/closed) are sent as plaintext metadata — do not put secrets
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 guest holds the same key as the host; the
145
- raw key itself is never sent over the wire.
146
- - **The join link is a single-use, expiring credential.** It embeds the session
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. It is consumed
149
- by the first guest who joins (and can't be reused, even after they leave) and
150
- expires on its own after ~10 minutes, so a leaked link has a short, bounded
151
- window of exposure.
152
- - **Exactly two participants, enforced by a lock.** The first guest to
153
- authenticate locks the session; nobody else can join after that.
154
- - **The peer is untrusted input, not an instruction source.** Messages from the
155
- other agent are data to reason about, not commands to execute. The etiquette
156
- skill directs each agent to require its own human's sign-off before writing
157
- files, running risky commands, or declaring a fix "confirmed" based on
158
- something the peer said.
159
- - **Everything is ephemeral.** The session tears down destroying the relay,
160
- the `cloudflared` child process, and the on-disk log on an explicit
161
- `tunnel_close`, after 30 minutes of no messages (idle timeout), or when the
162
- host's process exits.
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 (159 tests, TDD)
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
- - Join-link rotation (re-issuing a fresh link mid-session; note that links are already single-use and expiring — see the security model above)
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 two developers' Claude agents exchange messages directly
45
- through a host-owned, ephemeral relay, without a human copy-pasting between
46
- them. Understanding what is and isn't protected is important before you
47
- share a join link with anyone.
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
- both participants' display names, and system/connection events (joined,
56
- left, idle, closed) cross the tunnel as **plaintext**. Do not put secrets
57
- in the goal or display name.
58
- - **Authentication is proof-of-key-possession, not key transmission.** The
59
- join link embeds a session key. The guest's client proves it holds that
60
- key via an HMAC challenge/response; the raw key itself is never sent over
61
- the wire. Because the join link contains the key, **treat the join link
62
- like a password** — share it only over a trusted, already-authenticated
63
- channel (e.g. a Slack DM to a known teammate), not in a public channel or
64
- ticket.
65
- - **Join links are single-use and expiring.** A join link is consumed by the
66
- first guest who successfully authenticates and can never be redeemed again —
67
- even after that guest disconnects. Links also expire on their own (10 minutes
68
- by default), so a link that is never used stops working. This bounds the
69
- damage from a leaked link to a short window before it is used or expires.
70
- - **Single-guest lock.** The first participant who successfully
71
- authenticates as guest locks the session. Sessions are strictly two-party;
72
- a second concurrent join attempt is rejected ("tunnel full"), and any join
73
- after the link has been consumed is rejected ("join link already used").
74
- - **Peer input is untrusted.** Everything a peer sends over the tunnel is
75
- data, never an instruction. The bundled `tunnel-etiquette` skill
76
- instructs each agent to treat incoming peer messages as untrusted input
77
- and to get its own human's explicit OK before writing files, running
78
- risky commands, or declaring a fix "confirmed" based on something the
79
- peer said.
80
- - **Ephemeral by design.** A session and everything tied to it — the
81
- in-process relay, the cloudflared child process, the throwaway Quick
82
- Tunnel URL, and the on-disk session log are torn down on: an explicit
83
- `tunnel_close`, an idle timeout (30 minutes with no messages), or the
84
- host process exiting. Nothing persists past teardown.
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, both display names,
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 link can still be redeemed within its window, before your guest
128
- joins.** Join links are single-use and expire (10 minutes by default), so a
129
- leaked link that is never used, has already been used, or has aged out can no
130
- longer admit anyone. The residual risk is a race: if a link leaks and an
131
- attacker redeems it faster than your intended guest — within the expiry
132
- window and before that guest connects — the attacker consumes the single-use
133
- link, joins as the guest, and locks out the real one. Share links only over
134
- trusted channels, and open a fresh tunnel if you suspect a link was exposed
135
- before it was used. There is no in-session key rotation.
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
- - **Strictly two-party.** The protocol only supports one host and one
140
- guest per session. There is no support for additional participants,
141
- multi-party relays, or host-offline/async delivery in this MVP.
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
- more than two participants, alternative transports (ngrok, WebRTC),
153
- in-session key/link rotation, and encryption of the goal or other
154
- connection metadata. These may be considered for future versions but
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;
@@ -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
+ }
@@ -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(tunnelId: string);
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
  }
@@ -1,37 +1,29 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { SESSIONS_DIR } from '../config.js';
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
- filePath;
8
- // Set once delete() has run. After that point append()/record() must be
9
- // no-ops with respect to the file and in-memory store — otherwise a late
10
- // event (e.g. a guest socket's 'close' handler firing after teardown) can
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(tunnelId) {
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
- this.seqCounter = Math.max(this.seqCounter, finalized.seq);
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;
@@ -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
+ }
@@ -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 mintLink(publicBaseUrl: string, tunnelId: string, key: Key): string;
9
+ export declare function mintInvite(publicBaseUrl: string, tunnelId: string, key: Key, token: string): string;
9
10
  export declare function parseLink(link: string): JoinLink;
@@ -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 mintLink(publicBaseUrl, tunnelId, key) {
7
- const wsBase = publicBaseUrl.replace(/^http/, 'ws'); // https->wss, 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
- return { tunnelId: m[1], key: keyFromBase64url(keyPart), wsUrl: urlPart };
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
  }