tunnel-mcp 0.1.9 → 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,47 @@ 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 |
13
49
 
14
50
  ## [0.1.9] - 2026-07-02
15
51
 
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
 
@@ -107,60 +108,81 @@ the session. It is **single-use and expires after ~10 minutes**
107
108
 
108
109
  > "Join this tunnel: `<link>`"
109
110
 
110
- Claude calls `tunnel_join({ joinLink })`, learns the goal, and the session is
111
- 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.
112
125
 
113
126
  **Both** — the agents converse turn-by-turn using `tunnel_say` to send and
114
- `tunnel_listen` to wait for the next reply, checking in with their humans as
115
- 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.
116
130
 
117
- **Either side** ends the session with `tunnel_close`, which tears down the relay
118
- 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.
119
135
 
120
136
  ## Tools
121
137
 
122
- | Tool | Who | Purpose |
123
- | ---------------------------------------- | ----- | ---------------------------------------------------------- |
124
- | `tunnel_open({goal})` | host | Start the relay + Quick Tunnel and get back a join link. |
125
- | `tunnel_join({joinLink})` | guest | Dial into a host's tunnel using the link and authenticate. |
126
- | `tunnel_say({text})` | both | Send a message to the peer. |
127
- | `tunnel_listen({sinceSeq?, timeoutMs?})` | both | Wait for the next message(s) from the peer. |
128
- | `tunnel_status()` | both | Inspect the current session (connected, idle, etc.). |
129
- | `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. |
130
147
 
131
148
  ## Security model
132
149
 
133
150
  tunnel-mcp is a security-sensitive tool by nature — it opens a live channel
134
- 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:
135
152
 
136
153
  - **Chat message bodies are end-to-end encrypted.** Every `tunnel_say` body is
137
154
  sealed with NaCl `secretbox` (XSalsa20-Poly1305, via `tweetnacl`) before it
138
155
  crosses the `cloudflared` pipe. The relay and the pipe only ever see
139
156
  ciphertext for chat bodies.
140
- - **The goal, both display names, and system events are plaintext.** The
141
- `tunnel_open` goal, each participant's name, and connection events
142
- (joined/left/idle/closed) are sent as plaintext metadata — do not put secrets
143
- 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.
144
161
  - **Authentication is proof-of-key-possession, not key transmission.** Joining
145
- uses an HMAC challenge to prove the guest holds the same key as the host; the
146
- raw key itself is never sent over the wire.
147
- - **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
148
165
  key, so treat it like a password — share it only over a channel you already
149
- trust (Slack DM, etc.), never in a public issue, PR, or chat. It is consumed
150
- by the first guest who joins (and can't be reused, even after they leave) and
151
- expires on its own after ~10 minutes, so a leaked link has a short, bounded
152
- window of exposure.
153
- - **Exactly two participants, enforced by a lock.** The first guest to
154
- authenticate locks the session; nobody else can join after that.
155
- - **The peer is untrusted input, not an instruction source.** Messages from the
156
- other agent are data to reason about, not commands to execute. The etiquette
157
- skill directs each agent to require its own human's sign-off before writing
158
- files, running risky commands, or declaring a fix "confirmed" based on
159
- something the peer said.
160
- - **Everything is ephemeral.** The session tears down destroying the relay,
161
- the `cloudflared` child process, and the on-disk log on an explicit
162
- `tunnel_close`, after 30 minutes of no messages (idle timeout), or when the
163
- 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.
164
186
 
165
187
  See [SECURITY.md](./SECURITY.md) for the full threat model and how to report a
166
188
  vulnerability.
@@ -176,7 +198,7 @@ vulnerability.
176
198
 
177
199
  ```bash
178
200
  npm ci # install dependencies
179
- npm test # run the test suite (159 tests, TDD)
201
+ npm test # run the test suite (198 tests, TDD)
180
202
  npm run build # compile TypeScript
181
203
  npm run lint # eslint
182
204
  npm run format:check # prettier --check .
@@ -210,9 +232,8 @@ system DNS already resolves `*.trycloudflare.com`.
210
232
  This is an MVP. The following are explicitly out of scope for now:
211
233
 
212
234
  - Host-offline / asynchronous messaging
213
- - More than two participants in a session
214
235
  - Alternative transports (ngrok, WebRTC)
215
- - 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)
216
237
  - Encrypting the goal or other metadata
217
238
 
218
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;
@@ -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
  }
@@ -1,10 +1,16 @@
1
1
  import { Key } from './crypto.js';
2
2
  export type MessageKind = 'chat' | 'system' | 'presence';
3
- export type Role = 'host' | 'guest';
3
+ export type ParticipantId = string;
4
+ export interface RosterEntry {
5
+ id: ParticipantId;
6
+ name: string;
7
+ isHost: boolean;
8
+ connected: boolean;
9
+ }
4
10
  export interface WireMessage {
5
11
  id: string;
6
12
  seq: number;
7
- from: Role;
13
+ from: ParticipantId;
8
14
  kind: MessageKind;
9
15
  body: string;
10
16
  ts: number;
@@ -12,14 +18,16 @@ export interface WireMessage {
12
18
  export interface PlainMessage {
13
19
  id: string;
14
20
  seq: number;
15
- from: Role;
21
+ from: ParticipantId;
22
+ fromName?: string;
16
23
  kind: MessageKind;
17
24
  text: string;
18
25
  ts: number;
19
26
  }
20
27
  export declare function newId(): string;
21
- export declare function buildChat(from: Role, text: string, key: Key): WireMessage;
22
- export declare function buildSystem(from: Role, text: string): WireMessage;
28
+ export declare function newParticipantId(): ParticipantId;
29
+ export declare function buildChat(from: ParticipantId, text: string, key: Key): WireMessage;
30
+ export declare function buildSystem(from: ParticipantId, text: string): WireMessage;
23
31
  export declare function decrypt(msg: WireMessage, key: Key): PlainMessage;
24
32
  export type ControlFrame = {
25
33
  t: 'challenge';
@@ -29,10 +37,13 @@ export type ControlFrame = {
29
37
  response: string;
30
38
  name: string;
31
39
  sinceSeq: number;
40
+ token: string;
41
+ protocolVersion: number;
32
42
  } | {
33
43
  t: 'auth_ok';
34
44
  goal: string;
35
- peerName: string;
45
+ selfId: ParticipantId;
46
+ roster: RosterEntry[];
36
47
  backlog: WireMessage[];
37
48
  } | {
38
49
  t: 'auth_fail';
@@ -43,6 +54,9 @@ export type ControlFrame = {
43
54
  } | {
44
55
  t: 'send';
45
56
  msg: WireMessage;
57
+ } | {
58
+ t: 'roster';
59
+ members: RosterEntry[];
46
60
  };
47
61
  export declare function encodeFrame(frame: ControlFrame): string;
48
62
  export declare function decodeFrame(data: string): ControlFrame;
@@ -3,6 +3,9 @@ import { seal, open } from './crypto.js';
3
3
  export function newId() {
4
4
  return crypto.randomBytes(8).toString('hex');
5
5
  }
6
+ export function newParticipantId() {
7
+ return crypto.randomBytes(8).toString('hex');
8
+ }
6
9
  export function buildChat(from, text, key) {
7
10
  return { id: newId(), seq: -1, from, kind: 'chat', body: seal(text, key), ts: 0 };
8
11
  }