tunnel-mcp 0.1.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 ADDED
@@ -0,0 +1,46 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+
12
+ - Nothing yet.
13
+
14
+ ## [0.1.0] - 2026-06-30
15
+
16
+ ### Added
17
+
18
+ - Initial release of `tunnel-mcp`, an MCP server that lets two developers'
19
+ Claude agents talk directly to each other through a host-owned, ephemeral,
20
+ end-to-end-encrypted relay.
21
+ - Six MCP tools: `tunnel_open`, `tunnel_join`, `tunnel_say`, `tunnel_listen`,
22
+ `tunnel_status`, and `tunnel_close`.
23
+ - Host-owned, ephemeral relay: the initiator's MCP process becomes an
24
+ in-process WebSocket relay exposed via a throwaway `cloudflared` Quick
25
+ Tunnel, so both sides dial outbound and the tunnel works through
26
+ firewalls/NAT with no port-forwarding.
27
+ - End-to-end encrypted chat message bodies using NaCl secretbox
28
+ (XSalsa20-Poly1305, via `tweetnacl`), so the `cloudflared` pipe only ever
29
+ sees ciphertext for chat bodies.
30
+ - HMAC proof-of-key-possession authentication; the raw session key is never
31
+ sent over the wire.
32
+ - Single-guest lock: the first authenticated guest locks the session to
33
+ exactly two participants.
34
+ - Three teardown triggers for ephemeral sessions: explicit `tunnel_close`,
35
+ 30-minute idle timeout, or host process exit — each destroys the relay,
36
+ the `cloudflared` child process, and the on-disk log.
37
+ - Atomic `cloudflared` auto-download to `~/.tunnel/bin` on first use when
38
+ not already available on `PATH`.
39
+ - `tunnel-etiquette` skill, installable into a Claude skills directory, that
40
+ instructs agents to treat peer messages as untrusted data and to require
41
+ their human's OK before writing files, running risky commands, or
42
+ declaring a fix "confirmed".
43
+ - Test suite of 109 tests built with vitest, developed test-first (TDD).
44
+
45
+ [Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.0...HEAD
46
+ [0.1.0]: https://github.com/zachlikefolio/tunnel-mcp/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zachary Kehl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,166 @@
1
+ # tunnel-mcp
2
+
3
+ **A direct, end-to-end-encrypted tunnel between two developers' Claude agents — no human copy-paste required.**
4
+
5
+ [![CI](https://github.com/zachlikefolio/tunnel-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/zachlikefolio/tunnel-mcp/actions/workflows/ci.yml)
6
+ [![npm version](https://img.shields.io/npm/v/tunnel-mcp)](https://www.npmjs.com/package/tunnel-mcp)
7
+ [![license](https://img.shields.io/npm/l/tunnel-mcp)](./LICENSE)
8
+ ![node](https://img.shields.io/badge/node-%3E%3D20-brightgreen)
9
+
10
+ When two developers each run a Claude agent and need those agents to collaborate,
11
+ the usual workaround is a human sitting in the middle, copy-pasting messages from
12
+ one chat window to the other. **tunnel-mcp** removes that human. It's an MCP
13
+ server that lets one developer's agent open a throwaway, encrypted tunnel and the
14
+ other developer's agent dial straight into it, so the two agents can talk to each
15
+ other directly — while their humans stay in control of what actually happens to
16
+ the filesystem or the shell.
17
+
18
+ ## How it works
19
+
20
+ One developer (the **host**) calls `tunnel_open`. Their local `tunnel-mcp`
21
+ process becomes an in-process WebSocket relay and exposes it to the internet via
22
+ a throwaway `cloudflared` Quick Tunnel — no port-forwarding, no server to
23
+ provision. The other developer (the **guest**) calls `tunnel_join` with the link
24
+ the host shares, and their agent dials outbound to that same tunnel. Because both
25
+ sides only ever make outbound connections, it works from behind ordinary
26
+ firewalls and NAT.
27
+
28
+ ```
29
+ Host machine Guest machine
30
+ ┌───────────────────┐ outbound HTTPS ┌───────────────────┐
31
+ │ Claude (host) │ wss:// │ Claude (guest) │
32
+ │ │ │ ┌──────────────┐ │ │ │
33
+ │ tunnel_open/say/ │──────▶ cloudflared │◀────────│ tunnel_join/say/ │
34
+ │ listen/close │ │ Quick Tunnel │─────────▶ listen/close │
35
+ │ │ │ └──────────────┘ │ │ │
36
+ │ in-process relay │ └───────────────────┘
37
+ └───────────────────┘
38
+ ```
39
+
40
+ The relay, the `cloudflared` child process, and the on-disk session log all live
41
+ only for the lifetime of the session and are destroyed on teardown.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ npm install -g tunnel-mcp
47
+ # or, without installing:
48
+ npx tunnel-mcp
49
+ ```
50
+
51
+ Register it with Claude Code (both developers do this once):
52
+
53
+ ```bash
54
+ claude mcp add tunnel -- tunnel-mcp
55
+ ```
56
+
57
+ Install the etiquette skill so each agent knows how to behave inside a tunnel
58
+ (treat the peer as untrusted input, check with its human before acting on
59
+ anything the peer says). Copy `skill/tunnel-etiquette/` from this repo into your
60
+ `~/.claude/skills/` directory (or your plugin's skills directory).
61
+
62
+ `cloudflared` is auto-downloaded to `~/.tunnel/bin` the first time it's needed if
63
+ it isn't already on your `PATH` — there's nothing extra to install.
64
+
65
+ ## Quickstart
66
+
67
+ **Host** — ask Claude to open a tunnel with a goal:
68
+
69
+ > "Open a tunnel to pair on debugging the checkout flow."
70
+
71
+ Claude calls `tunnel_open({ goal })` and returns a join link. Share that link
72
+ 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.
74
+
75
+ **Guest** — paste the link and ask Claude to join:
76
+
77
+ > "Join this tunnel: `<link>`"
78
+
79
+ Claude calls `tunnel_join({ joinLink })`, learns the goal, and the session is
80
+ now locked to just the two of you.
81
+
82
+ **Both** — the agents converse turn-by-turn using `tunnel_say` to send and
83
+ `tunnel_listen` to wait for the next reply, checking in with their humans as
84
+ needed.
85
+
86
+ **Either side** ends the session with `tunnel_close`, which tears down the relay
87
+ and destroys the session log.
88
+
89
+ ## Tools
90
+
91
+ | Tool | Who | Purpose |
92
+ | ---------------------------------------- | ----- | ---------------------------------------------------------- |
93
+ | `tunnel_open({goal})` | host | Start the relay + Quick Tunnel and get back a join link. |
94
+ | `tunnel_join({joinLink})` | guest | Dial into a host's tunnel using the link and authenticate. |
95
+ | `tunnel_say({text})` | both | Send a message to the peer. |
96
+ | `tunnel_listen({sinceSeq?, timeoutMs?})` | both | Wait for the next message(s) from the peer. |
97
+ | `tunnel_status()` | both | Inspect the current session (connected, idle, etc.). |
98
+ | `tunnel_close({summary?})` | both | End the session and tear down the relay. |
99
+
100
+ ## Security model
101
+
102
+ tunnel-mcp is a security-sensitive tool by nature — it opens a live channel
103
+ between two AI agents. Here's exactly what it does and does not protect:
104
+
105
+ - **Chat message bodies are end-to-end encrypted.** Every `tunnel_say` body is
106
+ sealed with NaCl `secretbox` (XSalsa20-Poly1305, via `tweetnacl`) before it
107
+ crosses the `cloudflared` pipe. The relay and the pipe only ever see
108
+ ciphertext for chat bodies.
109
+ - **The goal, both display names, and system events are plaintext.** The
110
+ `tunnel_open` goal, each participant's name, and connection events
111
+ (joined/left/idle/closed) are sent as plaintext metadata — do not put secrets
112
+ in the goal string or a display name.
113
+ - **Authentication is proof-of-key-possession, not key transmission.** Joining
114
+ uses an HMAC challenge to prove the guest holds the same key as the host; the
115
+ raw key itself is never sent over the wire.
116
+ - **The join link is a credential.** It embeds the session key, so treat it
117
+ like a password — share it only over a channel you already trust (Slack DM,
118
+ etc.), never in a public issue, PR, or chat.
119
+ - **Exactly two participants, enforced by a lock.** The first guest to
120
+ authenticate locks the session; nobody else can join after that.
121
+ - **The peer is untrusted input, not an instruction source.** Messages from the
122
+ other agent are data to reason about, not commands to execute. The etiquette
123
+ skill directs each agent to require its own human's sign-off before writing
124
+ files, running risky commands, or declaring a fix "confirmed" based on
125
+ something the peer said.
126
+ - **Everything is ephemeral.** The session tears down — destroying the relay,
127
+ the `cloudflared` child process, and the on-disk log — on an explicit
128
+ `tunnel_close`, after 30 minutes of no messages (idle timeout), or when the
129
+ host's process exits.
130
+
131
+ See [SECURITY.md](./SECURITY.md) for the full threat model and how to report a
132
+ vulnerability.
133
+
134
+ ## Requirements
135
+
136
+ - Node.js >= 20
137
+ - A Claude MCP client (e.g., Claude Code)
138
+ - `cloudflared` — auto-installed to `~/.tunnel/bin` on first use if not already
139
+ on your `PATH`
140
+
141
+ ## Development
142
+
143
+ ```bash
144
+ npm ci # install dependencies
145
+ npm test # run the test suite (109 tests, TDD)
146
+ npm run build # compile TypeScript
147
+ npm run lint # eslint
148
+ npm run format:check # prettier --check .
149
+ npm run test:coverage # vitest run --coverage
150
+ ```
151
+
152
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for how to propose changes.
153
+
154
+ ## Roadmap / not yet supported
155
+
156
+ This is an MVP. The following are explicitly out of scope for now:
157
+
158
+ - Host-offline / asynchronous messaging
159
+ - More than two participants in a session
160
+ - Alternative transports (ngrok, WebRTC)
161
+ - Join-link rotation or one-time tokens
162
+ - Encrypting the goal or other metadata
163
+
164
+ ## License
165
+
166
+ MIT — see [LICENSE](./LICENSE).
package/SECURITY.md ADDED
@@ -0,0 +1,124 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ tunnel-mcp is pre-1.0 and moving quickly. Only the latest `0.1.x` release
6
+ receives security fixes.
7
+
8
+ | Version | Supported |
9
+ | ------- | ------------------ |
10
+ | 0.1.x | :white_check_mark: |
11
+ | < 0.1 | :x: |
12
+
13
+ ## Reporting a Vulnerability
14
+
15
+ Please **do not open a public GitHub issue** for security vulnerabilities.
16
+
17
+ The preferred way to report a vulnerability is through GitHub's private
18
+ security advisories:
19
+
20
+ 1. Go to the [tunnel-mcp repository](https://github.com/zachlikefolio/tunnel-mcp).
21
+ 2. Open the **Security** tab.
22
+ 3. Click **Report a vulnerability** to open a new draft security advisory.
23
+
24
+ This creates a private conversation with the maintainer and lets us
25
+ coordinate a fix and a disclosure timeline before any details become public.
26
+
27
+ If you cannot use GitHub's advisory flow, you may instead email
28
+ **zach@likefolio.com** with details of the issue. Please include:
29
+
30
+ - A description of the vulnerability and its potential impact.
31
+ - Steps to reproduce, or a proof-of-concept, if available.
32
+ - The version/commit of tunnel-mcp you tested against.
33
+ - Any suggested remediation, if you have one.
34
+
35
+ ### What to expect
36
+
37
+ - **Acknowledgement**: you should hear back within a few days of your
38
+ report.
39
+ - **Updates**: we'll keep you posted as we investigate and work on a fix.
40
+ - **Credit**: reporters are credited in the advisory and/or release notes
41
+ once a fix ships, unless you tell us you'd prefer to remain anonymous.
42
+
43
+ ## Security Model
44
+
45
+ tunnel-mcp lets two developers' Claude agents exchange messages directly
46
+ through a host-owned, ephemeral relay, without a human copy-pasting between
47
+ them. Understanding what is and isn't protected is important before you
48
+ share a join link with anyone.
49
+
50
+ - **Chat message bodies are end-to-end encrypted.** The text passed to
51
+ `tunnel_say` is sealed with NaCl `secretbox` (XSalsa20-Poly1305, via
52
+ `tweetnacl`) using a key that is never transmitted. The cloudflared
53
+ pipe — and the Cloudflare edge it runs over — only ever sees ciphertext
54
+ for chat message bodies.
55
+ - **Metadata is plaintext.** The `goal` passed to `tunnel_open`/`tunnel_join`,
56
+ both participants' display names, and system/connection events (joined,
57
+ left, idle, closed) cross the tunnel as **plaintext**. Do not put secrets
58
+ in the goal or display name.
59
+ - **Authentication is proof-of-key-possession, not key transmission.** The
60
+ join link embeds a session key. The guest's client proves it holds that
61
+ key via an HMAC challenge/response; the raw key itself is never sent over
62
+ the wire. Because the join link contains the key, **treat the join link
63
+ like a password** — share it only over a trusted, already-authenticated
64
+ channel (e.g. a Slack DM to a known teammate), not in a public channel or
65
+ ticket.
66
+ - **Single-guest lock.** The first participant who successfully
67
+ authenticates as guest locks the session. Sessions are strictly two-party;
68
+ a second join attempt is rejected.
69
+ - **Peer input is untrusted.** Everything a peer sends over the tunnel is
70
+ data, never an instruction. The bundled `tunnel-etiquette` skill
71
+ instructs each agent to treat incoming peer messages as untrusted input
72
+ and to get its own human's explicit OK before writing files, running
73
+ risky commands, or declaring a fix "confirmed" based on something the
74
+ peer said.
75
+ - **Ephemeral by design.** A session and everything tied to it — the
76
+ in-process relay, the cloudflared child process, the throwaway Quick
77
+ Tunnel URL, and the on-disk session log — are torn down on: an explicit
78
+ `tunnel_close`, an idle timeout (30 minutes with no messages), or the
79
+ host process exiting. Nothing persists past teardown.
80
+
81
+ ## Known Limitations / Threat Model
82
+
83
+ This is an MVP and it is important to be honest about what it does **not**
84
+ protect against:
85
+
86
+ - **The relay path sees metadata in the clear.** The cloudflared Quick
87
+ Tunnel is a real network hop through Cloudflare's edge. While chat
88
+ message bodies are encrypted end-to-end, the goal, both display names,
89
+ and system/connection events are visible in plaintext to anything that
90
+ can observe that path (including Cloudflare's infrastructure). Do not
91
+ put secrets in the goal or names.
92
+ - **No link rotation or expiry beyond session teardown.** A join link is
93
+ valid for the lifetime of the session. If a join link leaks (pasted into
94
+ the wrong channel, logged, etc.) before the host closes the session,
95
+ anyone with that link can join as the guest — up until the host runs
96
+ `tunnel_close`, the session idles out, or the host process exits. There
97
+ is currently no way to rotate the key or issue single-use/expiring
98
+ tokens.
99
+ - **The goal is never encrypted.** By design, the goal string is plaintext
100
+ metadata used for connection setup and display; it receives no
101
+ confidentiality protection at any layer.
102
+ - **Strictly two-party.** The protocol only supports one host and one
103
+ guest per session. There is no support for additional participants,
104
+ multi-party relays, or host-offline/async delivery in this MVP.
105
+ - **"Trusting" a peer only goes as far as your own agent's guardrails.**
106
+ tunnel-mcp does not sandbox or validate what a peer sends beyond
107
+ transport-level auth. The confidentiality/integrity of your own
108
+ workspace depends on the `tunnel-etiquette` skill being installed and
109
+ your agent honoring it (treating peer messages as untrusted data,
110
+ requiring human approval for file writes, running commands, or
111
+ confirming fixes). If you disable or bypass that skill, a malicious or
112
+ compromised peer's messages could otherwise be misinterpreted as
113
+ instructions by an unguarded agent.
114
+ - **Out of scope for this release**: host-offline/async messaging,
115
+ more than two participants, alternative transports (ngrok, WebRTC),
116
+ link rotation or one-time join tokens, and encryption of the goal or
117
+ other connection metadata. These may be considered for future versions
118
+ but should not be assumed to exist today.
119
+
120
+ If you find a way to break any of the guarantees above (e.g. read a chat
121
+ message body without the key, join a locked session, or get an agent to
122
+ treat peer input as trusted instructions bypassing the etiquette skill),
123
+ please report it via the process described above — that is exactly the
124
+ kind of issue we want to hear about.
@@ -0,0 +1,19 @@
1
+ export declare function cloudflaredBinName(platform: NodeJS.Platform): string;
2
+ export declare function cloudflaredDownloadUrl(platform: NodeJS.Platform, arch: string): string;
3
+ export interface DownloadDeps {
4
+ fetchImpl?: typeof fetch;
5
+ }
6
+ /**
7
+ * Download cloudflared from `url` and install it at `destBinPath`.
8
+ *
9
+ * Atomic: the binary is downloaded/extracted into a unique location under
10
+ * os.tmpdir() and only moved into `destBinPath` once it is fully present and
11
+ * valid, so a failed/partial download never poisons the destination (callers
12
+ * that check `fs.existsSync(destBinPath)` to skip re-downloading are safe).
13
+ *
14
+ * Any failure anywhere in this path (network, tar extraction, fs ops) is
15
+ * caught, temp artifacts (and any partially-installed dest) are cleaned up,
16
+ * and a single readable error with a manual-install pointer is thrown.
17
+ */
18
+ export declare function downloadCloudflared(url: string, destBinPath: string, deps?: DownloadDeps): Promise<void>;
19
+ export declare function ensureCloudflared(): Promise<string>;
@@ -0,0 +1,130 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { execSync, execFileSync } from 'node:child_process';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { Readable } from 'node:stream';
8
+ import { BIN_DIR } from '../config.js';
9
+ const RELEASE_BASE = 'https://github.com/cloudflare/cloudflared/releases/latest/download';
10
+ const MANUAL_INSTALL_POINTER = 'https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/';
11
+ function arch2cf(arch) {
12
+ if (arch === 'x64')
13
+ return 'amd64';
14
+ if (arch === 'arm64')
15
+ return 'arm64';
16
+ throw new Error(`unsupported arch: ${arch}`);
17
+ }
18
+ export function cloudflaredBinName(platform) {
19
+ return platform === 'win32' ? 'cloudflared.exe' : 'cloudflared';
20
+ }
21
+ export function cloudflaredDownloadUrl(platform, arch) {
22
+ const a = arch2cf(arch);
23
+ if (platform === 'darwin')
24
+ return `${RELEASE_BASE}/cloudflared-darwin-${a}.tgz`;
25
+ if (platform === 'linux')
26
+ return `${RELEASE_BASE}/cloudflared-linux-${a}`;
27
+ if (platform === 'win32')
28
+ return `${RELEASE_BASE}/cloudflared-windows-${a}.exe`;
29
+ throw new Error(`unsupported platform: ${platform}`);
30
+ }
31
+ function onPath() {
32
+ try {
33
+ const cmd = process.platform === 'win32' ? 'where cloudflared' : 'command -v cloudflared';
34
+ const out = execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] })
35
+ .toString()
36
+ .trim();
37
+ return out.split('\n')[0] || null;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ // Move src -> dest atomically. fs.renameSync is atomic within a filesystem; if src/dest
44
+ // straddle devices (EXDEV) fall back to copy+unlink, which is the best available
45
+ // approximation (still: dest only appears once the copy has fully completed).
46
+ function moveIntoPlace(src, dest) {
47
+ try {
48
+ fs.renameSync(src, dest);
49
+ }
50
+ catch (err) {
51
+ if (err?.code === 'EXDEV') {
52
+ fs.copyFileSync(src, dest);
53
+ fs.unlinkSync(src);
54
+ }
55
+ else {
56
+ throw err;
57
+ }
58
+ }
59
+ }
60
+ function rmQuiet(p) {
61
+ try {
62
+ fs.rmSync(p, { recursive: true, force: true });
63
+ }
64
+ catch {
65
+ // best-effort cleanup; nothing useful to do if this fails
66
+ }
67
+ }
68
+ /**
69
+ * Download cloudflared from `url` and install it at `destBinPath`.
70
+ *
71
+ * Atomic: the binary is downloaded/extracted into a unique location under
72
+ * os.tmpdir() and only moved into `destBinPath` once it is fully present and
73
+ * valid, so a failed/partial download never poisons the destination (callers
74
+ * that check `fs.existsSync(destBinPath)` to skip re-downloading are safe).
75
+ *
76
+ * Any failure anywhere in this path (network, tar extraction, fs ops) is
77
+ * caught, temp artifacts (and any partially-installed dest) are cleaned up,
78
+ * and a single readable error with a manual-install pointer is thrown.
79
+ */
80
+ export async function downloadCloudflared(url, destBinPath, deps = {}) {
81
+ const fetchImpl = deps.fetchImpl ?? fetch;
82
+ const unique = crypto.randomUUID();
83
+ const tmpFile = path.join(os.tmpdir(), `cloudflared-${unique}.download`);
84
+ const tmpExtractDir = path.join(os.tmpdir(), `cloudflared-extract-${unique}`);
85
+ let destMayBePartial = false;
86
+ try {
87
+ const res = await fetchImpl(url);
88
+ if (!res.ok || !res.body) {
89
+ const status = !res.ok ? ` (status ${res.status})` : '';
90
+ throw new Error(`cloudflared download failed${status}`);
91
+ }
92
+ await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(tmpFile));
93
+ if (url.endsWith('.tgz')) {
94
+ fs.mkdirSync(tmpExtractDir, { recursive: true });
95
+ execFileSync('tar', ['-xzf', tmpFile, '-C', tmpExtractDir]); // extracts a `cloudflared` binary
96
+ const extractedBin = path.join(tmpExtractDir, 'cloudflared');
97
+ destMayBePartial = true;
98
+ moveIntoPlace(extractedBin, destBinPath);
99
+ }
100
+ else {
101
+ destMayBePartial = true;
102
+ moveIntoPlace(tmpFile, destBinPath);
103
+ }
104
+ fs.chmodSync(destBinPath, 0o755);
105
+ }
106
+ catch (err) {
107
+ // Never leave a partial/corrupt binary at the cached path.
108
+ if (destMayBePartial)
109
+ rmQuiet(destBinPath);
110
+ rmQuiet(tmpFile);
111
+ rmQuiet(tmpExtractDir);
112
+ const cause = err instanceof Error ? err.message : String(err);
113
+ throw new Error(`cloudflared download/install failed: ${cause}. Install it manually: ${MANUAL_INSTALL_POINTER}`);
114
+ }
115
+ rmQuiet(tmpFile);
116
+ rmQuiet(tmpExtractDir);
117
+ }
118
+ export async function ensureCloudflared() {
119
+ const onpath = onPath();
120
+ if (onpath)
121
+ return onpath;
122
+ const binName = cloudflaredBinName(process.platform);
123
+ const dest = path.join(BIN_DIR, binName);
124
+ if (fs.existsSync(dest))
125
+ return dest;
126
+ fs.mkdirSync(BIN_DIR, { recursive: true });
127
+ const url = cloudflaredDownloadUrl(process.platform, process.arch);
128
+ await downloadCloudflared(url, dest);
129
+ return dest;
130
+ }
@@ -0,0 +1,21 @@
1
+ export interface TunnelHandle {
2
+ publicUrl: string;
3
+ stop(): void;
4
+ }
5
+ export interface StartOptions {
6
+ timeoutMs?: number;
7
+ extraArgs?: string[];
8
+ attempts?: number;
9
+ intervalMs?: number;
10
+ healthCheck?: (url: string) => Promise<boolean>;
11
+ probeTimeoutMs?: number;
12
+ }
13
+ export declare function parsePublicUrl(line: string): string | null;
14
+ export declare function defaultHealthCheck(url: string): Promise<boolean>;
15
+ /**
16
+ * `extraArgs` exists for tests: it lets a fake binary (e.g. `node fake.mjs`) be
17
+ * launched in place of `cloudflared tunnel --url ...`. Production passes none.
18
+ * The URL is surfaced only after a health probe confirms the edge is reachable
19
+ * (cloudflared prints the hostname before routing is live).
20
+ */
21
+ export declare function startCloudflared(binPath: string, localPort: number, opts?: StartOptions): Promise<TunnelHandle>;
@@ -0,0 +1,120 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { CLOUDFLARED_URL_TIMEOUT_MS, CLOUDFLARED_HEALTH_ATTEMPTS, CLOUDFLARED_HEALTH_INTERVAL_MS, } from '../config.js';
3
+ const URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
4
+ // Bounds a single probe so a black-hole connection (or a caller-supplied
5
+ // healthCheck that hangs/throws) can't stall the health-check loop forever.
6
+ const DEFAULT_PROBE_TIMEOUT_MS = 5000;
7
+ export function parsePublicUrl(line) {
8
+ const m = line.match(URL_RE);
9
+ return m ? m[0] : null;
10
+ }
11
+ // Any HTTP response (even 404/502/426) means the Cloudflare edge is routing to us.
12
+ export async function defaultHealthCheck(url) {
13
+ try {
14
+ await fetch(url, { method: 'GET', signal: AbortSignal.timeout(DEFAULT_PROBE_TIMEOUT_MS) });
15
+ return true;
16
+ }
17
+ catch {
18
+ return false; // network/DNS error/timeout → edge not ready yet
19
+ }
20
+ }
21
+ // Races a single health-check attempt against a per-attempt timeout so that a
22
+ // caller-supplied `check` that throws, rejects, or simply never resolves can
23
+ // never leave the loop (and therefore the outer startCloudflared promise)
24
+ // hanging. Any failure mode here just counts as "not healthy yet".
25
+ function probeOnce(url, check, probeTimeoutMs) {
26
+ return new Promise((resolve) => {
27
+ let settled = false;
28
+ const finish = (ok) => {
29
+ if (!settled) {
30
+ settled = true;
31
+ resolve(ok);
32
+ }
33
+ };
34
+ const timer = setTimeout(() => finish(false), probeTimeoutMs);
35
+ Promise.resolve()
36
+ .then(() => check(url))
37
+ .then((ok) => {
38
+ clearTimeout(timer);
39
+ finish(ok);
40
+ })
41
+ .catch(() => {
42
+ clearTimeout(timer);
43
+ finish(false);
44
+ });
45
+ });
46
+ }
47
+ async function waitHealthy(url, attempts, intervalMs, check, probeTimeoutMs) {
48
+ for (let i = 0; i < attempts; i++) {
49
+ if (await probeOnce(url, check, probeTimeoutMs))
50
+ return true;
51
+ await new Promise((r) => setTimeout(r, intervalMs));
52
+ }
53
+ return false;
54
+ }
55
+ /**
56
+ * `extraArgs` exists for tests: it lets a fake binary (e.g. `node fake.mjs`) be
57
+ * launched in place of `cloudflared tunnel --url ...`. Production passes none.
58
+ * The URL is surfaced only after a health probe confirms the edge is reachable
59
+ * (cloudflared prints the hostname before routing is live).
60
+ */
61
+ export function startCloudflared(binPath, localPort, opts = {}) {
62
+ const args = opts.extraArgs ?? ['tunnel', '--url', `http://localhost:${localPort}`];
63
+ const timeoutMs = opts.timeoutMs ?? CLOUDFLARED_URL_TIMEOUT_MS;
64
+ const attempts = opts.attempts ?? CLOUDFLARED_HEALTH_ATTEMPTS;
65
+ const intervalMs = opts.intervalMs ?? CLOUDFLARED_HEALTH_INTERVAL_MS;
66
+ const check = opts.healthCheck ?? defaultHealthCheck;
67
+ const probeTimeoutMs = opts.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
68
+ return new Promise((resolve, reject) => {
69
+ const child = spawn(binPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
70
+ let settled = false;
71
+ const stop = () => {
72
+ try {
73
+ child.kill('SIGTERM');
74
+ }
75
+ catch {
76
+ /* gone */
77
+ }
78
+ };
79
+ const fail = (err) => {
80
+ if (!settled) {
81
+ settled = true;
82
+ clearTimeout(timer);
83
+ stop();
84
+ reject(err);
85
+ }
86
+ };
87
+ const timer = setTimeout(() => fail(new Error('cloudflared did not report a URL in time')), timeoutMs);
88
+ const onData = (buf) => {
89
+ for (const line of buf.toString().split('\n')) {
90
+ const url = parsePublicUrl(line);
91
+ if (url && !settled) {
92
+ settled = true;
93
+ clearTimeout(timer);
94
+ waitHealthy(url, attempts, intervalMs, check, probeTimeoutMs)
95
+ .then((ok) => {
96
+ if (ok)
97
+ resolve({ publicUrl: url, stop });
98
+ else {
99
+ stop();
100
+ reject(new Error('cloudflared tunnel never became reachable'));
101
+ }
102
+ })
103
+ .catch((err) => {
104
+ // Should be unreachable (waitHealthy/probeOnce never reject), but
105
+ // this guarantees the child is never orphaned and the outer
106
+ // promise always settles, even on a future bug or surprise throw.
107
+ stop();
108
+ const reason = err instanceof Error ? err.message : String(err);
109
+ reject(new Error(`cloudflared health check failed unexpectedly: ${reason}`));
110
+ });
111
+ return;
112
+ }
113
+ }
114
+ };
115
+ child.stdout?.on('data', onData);
116
+ child.stderr?.on('data', onData);
117
+ child.on('error', (err) => fail(err));
118
+ child.on('exit', (code) => fail(new Error(`cloudflared exited (${code})`)));
119
+ });
120
+ }
@@ -0,0 +1,9 @@
1
+ export declare const TUNNEL_HOME: string;
2
+ export declare const BIN_DIR: string;
3
+ export declare const SESSIONS_DIR: string;
4
+ export declare const DEFAULT_LISTEN_TIMEOUT_MS = 60000;
5
+ export declare const DEFAULT_IDLE_TEARDOWN_MS: number;
6
+ export declare const CLOUDFLARED_URL_TIMEOUT_MS = 30000;
7
+ export declare const CLOUDFLARED_HEALTH_ATTEMPTS = 10;
8
+ export declare const CLOUDFLARED_HEALTH_INTERVAL_MS = 1000;
9
+ export declare const OPEN_RETRY_ATTEMPTS = 3;
package/dist/config.js ADDED
@@ -0,0 +1,12 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ export const TUNNEL_HOME = path.join(os.homedir(), '.tunnel');
4
+ export const BIN_DIR = path.join(TUNNEL_HOME, 'bin');
5
+ export const SESSIONS_DIR = path.join(TUNNEL_HOME, 'sessions');
6
+ export const DEFAULT_LISTEN_TIMEOUT_MS = 60_000;
7
+ export const DEFAULT_IDLE_TEARDOWN_MS = 30 * 60_000;
8
+ // cloudflared startup robustness
9
+ export const CLOUDFLARED_URL_TIMEOUT_MS = 30_000; // wait for the URL line
10
+ export const CLOUDFLARED_HEALTH_ATTEMPTS = 10; // edge-reachability probes
11
+ export const CLOUDFLARED_HEALTH_INTERVAL_MS = 1_000; // delay between probes
12
+ export const OPEN_RETRY_ATTEMPTS = 3; // re-spawn attempts in session.open