tunnel-mcp 0.1.0 → 0.1.1
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 +12 -0
- package/README.md +9 -5
- package/SECURITY.md +19 -11
- package/dist/config.d.ts +1 -0
- package/dist/config.js +3 -0
- package/dist/relay/hostRelay.d.ts +4 -0
- package/dist/relay/hostRelay.js +29 -3
- package/dist/session.d.ts +2 -0
- package/dist/session.js +13 -3
- package/dist/tools.js +1 -1
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
11
11
|
|
|
12
12
|
- Nothing yet.
|
|
13
13
|
|
|
14
|
+
## [0.1.1] - 2026-07-01
|
|
15
|
+
|
|
16
|
+
### Security
|
|
17
|
+
|
|
18
|
+
- **Join links are now single-use and expiring.** A join link is consumed by
|
|
19
|
+
the first guest who successfully authenticates and can no longer be redeemed
|
|
20
|
+
afterward — even once that guest disconnects — and links expire on their own
|
|
21
|
+
after 10 minutes (`DEFAULT_JOIN_LINK_TTL_MS`). This bounds the damage from a
|
|
22
|
+
leaked link, which previously stayed valid for the whole session.
|
|
23
|
+
`tunnel_open` now returns `joinLinkExpiresInSec` so the host can tell the
|
|
24
|
+
human how long the link is good for.
|
|
25
|
+
|
|
14
26
|
## [0.1.0] - 2026-06-30
|
|
15
27
|
|
|
16
28
|
### Added
|
package/README.md
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://github.com/zachlikefolio/tunnel-mcp/actions/workflows/ci.yml)
|
|
6
6
|
[](https://www.npmjs.com/package/tunnel-mcp)
|
|
7
|
-
[](./LICENSE)
|
|
8
7
|

|
|
9
8
|
|
|
10
9
|
When two developers each run a Claude agent and need those agents to collaborate,
|
|
@@ -70,7 +69,9 @@ it isn't already on your `PATH` — there's nothing extra to install.
|
|
|
70
69
|
|
|
71
70
|
Claude calls `tunnel_open({ goal })` and returns a join link. Share that link
|
|
72
71
|
with the other developer over a trusted channel (Slack DM, etc.) — **it's a
|
|
73
|
-
secret**, since it contains the encryption key for the session.
|
|
72
|
+
secret**, since it contains the encryption key for the session. The link is
|
|
73
|
+
**single-use and expires after ~10 minutes** (`tunnel_open` reports
|
|
74
|
+
`joinLinkExpiresInSec`), so share it promptly.
|
|
74
75
|
|
|
75
76
|
**Guest** — paste the link and ask Claude to join:
|
|
76
77
|
|
|
@@ -113,9 +114,12 @@ between two AI agents. Here's exactly what it does and does not protect:
|
|
|
113
114
|
- **Authentication is proof-of-key-possession, not key transmission.** Joining
|
|
114
115
|
uses an HMAC challenge to prove the guest holds the same key as the host; the
|
|
115
116
|
raw key itself is never sent over the wire.
|
|
116
|
-
- **The join link is a credential.** It embeds the session
|
|
117
|
-
like a password — share it only over a channel you already
|
|
118
|
-
etc.), never in a public issue, PR, or chat.
|
|
117
|
+
- **The join link is a single-use, expiring credential.** It embeds the session
|
|
118
|
+
key, so treat it like a password — share it only over a channel you already
|
|
119
|
+
trust (Slack DM, etc.), never in a public issue, PR, or chat. It is consumed
|
|
120
|
+
by the first guest who joins (and can't be reused, even after they leave) and
|
|
121
|
+
expires on its own after ~10 minutes, so a leaked link has a short, bounded
|
|
122
|
+
window of exposure.
|
|
119
123
|
- **Exactly two participants, enforced by a lock.** The first guest to
|
|
120
124
|
authenticate locks the session; nobody else can join after that.
|
|
121
125
|
- **The peer is untrusted input, not an instruction source.** Messages from the
|
package/SECURITY.md
CHANGED
|
@@ -63,9 +63,15 @@ share a join link with anyone.
|
|
|
63
63
|
like a password** — share it only over a trusted, already-authenticated
|
|
64
64
|
channel (e.g. a Slack DM to a known teammate), not in a public channel or
|
|
65
65
|
ticket.
|
|
66
|
+
- **Join links are single-use and expiring.** A join link is consumed by the
|
|
67
|
+
first guest who successfully authenticates and can never be redeemed again —
|
|
68
|
+
even after that guest disconnects. Links also expire on their own (10 minutes
|
|
69
|
+
by default), so a link that is never used stops working. This bounds the
|
|
70
|
+
damage from a leaked link to a short window before it is used or expires.
|
|
66
71
|
- **Single-guest lock.** The first participant who successfully
|
|
67
72
|
authenticates as guest locks the session. Sessions are strictly two-party;
|
|
68
|
-
a second join attempt is rejected
|
|
73
|
+
a second concurrent join attempt is rejected ("tunnel full"), and any join
|
|
74
|
+
after the link has been consumed is rejected ("join link already used").
|
|
69
75
|
- **Peer input is untrusted.** Everything a peer sends over the tunnel is
|
|
70
76
|
data, never an instruction. The bundled `tunnel-etiquette` skill
|
|
71
77
|
instructs each agent to treat incoming peer messages as untrusted input
|
|
@@ -89,13 +95,15 @@ protect against:
|
|
|
89
95
|
and system/connection events are visible in plaintext to anything that
|
|
90
96
|
can observe that path (including Cloudflare's infrastructure). Do not
|
|
91
97
|
put secrets in the goal or names.
|
|
92
|
-
- **
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
- **A leaked link can still be redeemed within its window, before your guest
|
|
99
|
+
joins.** Join links are single-use and expire (10 minutes by default), so a
|
|
100
|
+
leaked link that is never used, has already been used, or has aged out can no
|
|
101
|
+
longer admit anyone. The residual risk is a race: if a link leaks and an
|
|
102
|
+
attacker redeems it faster than your intended guest — within the expiry
|
|
103
|
+
window and before that guest connects — the attacker consumes the single-use
|
|
104
|
+
link, joins as the guest, and locks out the real one. Share links only over
|
|
105
|
+
trusted channels, and open a fresh tunnel if you suspect a link was exposed
|
|
106
|
+
before it was used. There is no in-session key rotation.
|
|
99
107
|
- **The goal is never encrypted.** By design, the goal string is plaintext
|
|
100
108
|
metadata used for connection setup and display; it receives no
|
|
101
109
|
confidentiality protection at any layer.
|
|
@@ -113,9 +121,9 @@ protect against:
|
|
|
113
121
|
instructions by an unguarded agent.
|
|
114
122
|
- **Out of scope for this release**: host-offline/async messaging,
|
|
115
123
|
more than two participants, alternative transports (ngrok, WebRTC),
|
|
116
|
-
link rotation
|
|
117
|
-
|
|
118
|
-
|
|
124
|
+
in-session key/link rotation, and encryption of the goal or other
|
|
125
|
+
connection metadata. These may be considered for future versions but
|
|
126
|
+
should not be assumed to exist today.
|
|
119
127
|
|
|
120
128
|
If you find a way to break any of the guarantees above (e.g. read a chat
|
|
121
129
|
message body without the key, join a locked session, or get an agent to
|
package/dist/config.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export declare const BIN_DIR: string;
|
|
|
3
3
|
export declare const SESSIONS_DIR: string;
|
|
4
4
|
export declare const DEFAULT_LISTEN_TIMEOUT_MS = 60000;
|
|
5
5
|
export declare const DEFAULT_IDLE_TEARDOWN_MS: number;
|
|
6
|
+
export declare const DEFAULT_JOIN_LINK_TTL_MS: number;
|
|
6
7
|
export declare const CLOUDFLARED_URL_TIMEOUT_MS = 30000;
|
|
7
8
|
export declare const CLOUDFLARED_HEALTH_ATTEMPTS = 10;
|
|
8
9
|
export declare const CLOUDFLARED_HEALTH_INTERVAL_MS = 1000;
|
package/dist/config.js
CHANGED
|
@@ -5,6 +5,9 @@ export const BIN_DIR = path.join(TUNNEL_HOME, 'bin');
|
|
|
5
5
|
export const SESSIONS_DIR = path.join(TUNNEL_HOME, 'sessions');
|
|
6
6
|
export const DEFAULT_LISTEN_TIMEOUT_MS = 60_000;
|
|
7
7
|
export const DEFAULT_IDLE_TEARDOWN_MS = 30 * 60_000;
|
|
8
|
+
// Join links are single-use and expire after this window; a leaked link that
|
|
9
|
+
// is never used (or is reused after the guest joined) can't admit anyone.
|
|
10
|
+
export const DEFAULT_JOIN_LINK_TTL_MS = 10 * 60_000;
|
|
8
11
|
// cloudflared startup robustness
|
|
9
12
|
export const CLOUDFLARED_URL_TIMEOUT_MS = 30_000; // wait for the URL line
|
|
10
13
|
export const CLOUDFLARED_HEALTH_ATTEMPTS = 10; // edge-reachability probes
|
|
@@ -8,6 +8,7 @@ export interface HostRelayOptions {
|
|
|
8
8
|
goal: string;
|
|
9
9
|
hostName: string;
|
|
10
10
|
idleMs?: number;
|
|
11
|
+
joinTtlMs?: number;
|
|
11
12
|
}
|
|
12
13
|
export declare class HostRelay extends EventEmitter {
|
|
13
14
|
private opts;
|
|
@@ -19,9 +20,12 @@ export declare class HostRelay extends EventEmitter {
|
|
|
19
20
|
private challenges;
|
|
20
21
|
private idleTimer?;
|
|
21
22
|
private tearingDown;
|
|
23
|
+
private consumed;
|
|
24
|
+
private joinDeadline;
|
|
22
25
|
constructor(opts: HostRelayOptions, log: SessionLog);
|
|
23
26
|
start(): Promise<number>;
|
|
24
27
|
get peerConnected(): boolean;
|
|
28
|
+
armJoinDeadline(ttlMs?: number): void;
|
|
25
29
|
submitLocal(msg: WireMessage): WireMessage;
|
|
26
30
|
private resetIdle;
|
|
27
31
|
private submit;
|
package/dist/relay/hostRelay.js
CHANGED
|
@@ -3,7 +3,7 @@ import { EventEmitter } from 'node:events';
|
|
|
3
3
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
4
|
import { makeChallenge, verifyChallenge } from '../protocol/crypto.js';
|
|
5
5
|
import { encodeFrame, decodeFrame, buildSystem, } from '../protocol/messages.js';
|
|
6
|
-
import { DEFAULT_IDLE_TEARDOWN_MS } from '../config.js';
|
|
6
|
+
import { DEFAULT_IDLE_TEARDOWN_MS, DEFAULT_JOIN_LINK_TTL_MS } from '../config.js';
|
|
7
7
|
export class HostRelay extends EventEmitter {
|
|
8
8
|
opts;
|
|
9
9
|
log;
|
|
@@ -14,10 +14,16 @@ export class HostRelay extends EventEmitter {
|
|
|
14
14
|
challenges = new WeakMap();
|
|
15
15
|
idleTimer;
|
|
16
16
|
tearingDown = false;
|
|
17
|
+
// Single-use, expiring join link: the link is valid until `joinDeadline`, and
|
|
18
|
+
// is consumed by the first successful authentication so it can never be reused
|
|
19
|
+
// (even after that guest disconnects).
|
|
20
|
+
consumed = false;
|
|
21
|
+
joinDeadline;
|
|
17
22
|
constructor(opts, log) {
|
|
18
23
|
super();
|
|
19
24
|
this.opts = opts;
|
|
20
25
|
this.log = log;
|
|
26
|
+
this.joinDeadline = Date.now() + (opts.joinTtlMs ?? DEFAULT_JOIN_LINK_TTL_MS);
|
|
21
27
|
this.server = http.createServer();
|
|
22
28
|
this.wss = new WebSocketServer({ server: this.server, path: `/t/${opts.tunnelId}` });
|
|
23
29
|
this.wss.on('connection', (ws) => this.onConnection(ws));
|
|
@@ -39,6 +45,13 @@ export class HostRelay extends EventEmitter {
|
|
|
39
45
|
get peerConnected() {
|
|
40
46
|
return !!this.guest && this.guest.readyState === WebSocket.OPEN;
|
|
41
47
|
}
|
|
48
|
+
// (Re)start the single-use join link's expiry window. The session calls this
|
|
49
|
+
// once the link is actually minted (after cloudflared provisioning), so the
|
|
50
|
+
// window is measured from when the host receives the link to share — not from
|
|
51
|
+
// relay construction, which happens before provisioning could burn time.
|
|
52
|
+
armJoinDeadline(ttlMs) {
|
|
53
|
+
this.joinDeadline = Date.now() + (ttlMs ?? this.opts.joinTtlMs ?? DEFAULT_JOIN_LINK_TTL_MS);
|
|
54
|
+
}
|
|
42
55
|
submitLocal(msg) {
|
|
43
56
|
return this.submit(msg);
|
|
44
57
|
}
|
|
@@ -95,11 +108,24 @@ export class HostRelay extends EventEmitter {
|
|
|
95
108
|
ws.close();
|
|
96
109
|
return;
|
|
97
110
|
}
|
|
98
|
-
if (
|
|
99
|
-
ws.send(encodeFrame({ t: 'auth_fail', reason: '
|
|
111
|
+
if (Date.now() > this.joinDeadline) {
|
|
112
|
+
ws.send(encodeFrame({ t: 'auth_fail', reason: 'join link expired' }));
|
|
113
|
+
ws.close();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (this.consumed) {
|
|
117
|
+
// The single-use link was already redeemed. Distinguish the case
|
|
118
|
+
// where the original guest is still connected ('tunnel full') from a
|
|
119
|
+
// reuse attempt after they left ('join link already used').
|
|
120
|
+
const connected = !!this.guest && this.guest.readyState === WebSocket.OPEN;
|
|
121
|
+
ws.send(encodeFrame({
|
|
122
|
+
t: 'auth_fail',
|
|
123
|
+
reason: connected ? 'tunnel full' : 'join link already used',
|
|
124
|
+
}));
|
|
100
125
|
ws.close();
|
|
101
126
|
return;
|
|
102
127
|
}
|
|
128
|
+
this.consumed = true;
|
|
103
129
|
this.guest = ws;
|
|
104
130
|
this.guestName = frame.name;
|
|
105
131
|
const sinceSeq = Number.isFinite(frame.sinceSeq) ? frame.sinceSeq : 0;
|
package/dist/session.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export interface SessionDeps {
|
|
|
4
4
|
ensureCloudflared: () => Promise<string>;
|
|
5
5
|
startCloudflared: (bin: string, port: number) => Promise<TunnelHandle>;
|
|
6
6
|
idleMs?: number;
|
|
7
|
+
joinTtlMs?: number;
|
|
7
8
|
}
|
|
8
9
|
export interface SessionStatus {
|
|
9
10
|
role: Role;
|
|
@@ -30,6 +31,7 @@ export declare class TunnelSession {
|
|
|
30
31
|
tunnelId: string;
|
|
31
32
|
joinLink: string;
|
|
32
33
|
status: string;
|
|
34
|
+
joinLinkExpiresInSec: number;
|
|
33
35
|
}>;
|
|
34
36
|
join(joinLink: string, guestName: string): Promise<{
|
|
35
37
|
tunnelId: string;
|
package/dist/session.js
CHANGED
|
@@ -6,7 +6,7 @@ import { HostRelay } from './relay/hostRelay.js';
|
|
|
6
6
|
import { GuestClient } from './relay/guestClient.js';
|
|
7
7
|
import { ensureCloudflared as realEnsure } from './cloudflared/provision.js';
|
|
8
8
|
import { startCloudflared as realStart } from './cloudflared/tunnelProcess.js';
|
|
9
|
-
import { DEFAULT_LISTEN_TIMEOUT_MS, DEFAULT_IDLE_TEARDOWN_MS, OPEN_RETRY_ATTEMPTS, } from './config.js';
|
|
9
|
+
import { DEFAULT_LISTEN_TIMEOUT_MS, DEFAULT_IDLE_TEARDOWN_MS, DEFAULT_JOIN_LINK_TTL_MS, OPEN_RETRY_ATTEMPTS, } from './config.js';
|
|
10
10
|
const DEFAULT_DEPS = {
|
|
11
11
|
ensureCloudflared: realEnsure,
|
|
12
12
|
startCloudflared: (bin, port) => realStart(bin, port),
|
|
@@ -35,8 +35,9 @@ export class TunnelSession {
|
|
|
35
35
|
const key = generateKey();
|
|
36
36
|
const tunnelId = generateTunnelId();
|
|
37
37
|
const idleMs = this.deps.idleMs ?? DEFAULT_IDLE_TEARDOWN_MS;
|
|
38
|
+
const joinTtlMs = this.deps.joinTtlMs ?? DEFAULT_JOIN_LINK_TTL_MS;
|
|
38
39
|
const log = new SessionLog(tunnelId);
|
|
39
|
-
const relay = new HostRelay({ tunnelId, key, goal, hostName, idleMs }, log);
|
|
40
|
+
const relay = new HostRelay({ tunnelId, key, goal, hostName, idleMs, joinTtlMs }, log);
|
|
40
41
|
const port = await relay.start();
|
|
41
42
|
// Bounded retry: cloudflared may crash or never yield a URL; re-spawn before giving up.
|
|
42
43
|
let tunnel;
|
|
@@ -56,6 +57,10 @@ export class TunnelSession {
|
|
|
56
57
|
log.delete();
|
|
57
58
|
throw new Error(`could not establish a cloudflared tunnel after ${OPEN_RETRY_ATTEMPTS} attempts: ${String(lastErr)}`);
|
|
58
59
|
}
|
|
60
|
+
// Start the single-use link's expiry window now that the link exists —
|
|
61
|
+
// measured from mint time, not from relay construction (which happened
|
|
62
|
+
// before cloudflared provisioning could burn part of the window).
|
|
63
|
+
relay.armJoinDeadline();
|
|
59
64
|
const joinLink = mintLink(tunnel.publicUrl, tunnelId, key);
|
|
60
65
|
this.role = 'host';
|
|
61
66
|
this.key = key;
|
|
@@ -71,7 +76,12 @@ export class TunnelSession {
|
|
|
71
76
|
void this.close();
|
|
72
77
|
});
|
|
73
78
|
relay.submitLocal(buildSystem('host', `tunnel opened — goal: ${goal}`));
|
|
74
|
-
return {
|
|
79
|
+
return {
|
|
80
|
+
tunnelId,
|
|
81
|
+
joinLink,
|
|
82
|
+
status: 'waiting_for_guest',
|
|
83
|
+
joinLinkExpiresInSec: Math.round(joinTtlMs / 1000),
|
|
84
|
+
};
|
|
75
85
|
}
|
|
76
86
|
async join(joinLink, guestName) {
|
|
77
87
|
if (this.isOpen)
|
package/dist/tools.js
CHANGED
|
@@ -28,7 +28,7 @@ function register(server, name, schema, cb) {
|
|
|
28
28
|
}
|
|
29
29
|
export function registerTools(server, session, opts) {
|
|
30
30
|
register(server, 'tunnel_open', {
|
|
31
|
-
description: 'Open a tunnel as host and get a join link to share.',
|
|
31
|
+
description: 'Open a tunnel as host and get a join link to share. The link is a secret — share it over a trusted channel. It is single-use (works for exactly one guest) and expires (see joinLinkExpiresInSec in the result), so tell the human to share it promptly.',
|
|
32
32
|
inputSchema: { goal: z.string() },
|
|
33
33
|
}, async ({ goal }) => ok(await session.open(goal, opts.displayName)));
|
|
34
34
|
register(server, 'tunnel_join', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tunnel-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Let two developers' Claude agents talk directly through an ephemeral, end-to-end-encrypted tunnel.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"format": "prettier --write .",
|
|
29
29
|
"format:check": "prettier --check .",
|
|
30
30
|
"dev": "tsx src/index.ts",
|
|
31
|
+
"e2e": "tsx scripts/e2e-two-agents.ts",
|
|
31
32
|
"prepublishOnly": "npm run build"
|
|
32
33
|
},
|
|
33
34
|
"keywords": [
|
|
@@ -62,6 +63,7 @@
|
|
|
62
63
|
"zod": "^3.23.0"
|
|
63
64
|
},
|
|
64
65
|
"devDependencies": {
|
|
66
|
+
"@anthropic-ai/sdk": "^0.109.0",
|
|
65
67
|
"@types/node": "^20.14.0",
|
|
66
68
|
"@types/ws": "^8.5.10",
|
|
67
69
|
"@vitest/coverage-v8": "^2.0.0",
|