@debugg-ai/debugg-ai-mcp 4.4.1 → 5.0.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.
@@ -3,21 +3,18 @@
3
3
  *
4
4
  * MCP owns the tunnel lifecycle. It must validate that the user's claimed
5
5
  * localhost URL is actually reachable BEFORE calling the backend provision
6
- * API and BEFORE committing to the slow ngrok/browser-agent path. Without
7
- * these probes, unreachable apps result in a 5-minute false-positive pass
8
- * as the browser agent burns its step budget on ERR_NGROK_8012.
6
+ * API and BEFORE committing to the slow tunnel/browser-agent path. Without
7
+ * these probes, unreachable apps result in a 5-minute false-positive pass as
8
+ * the browser agent burns its step budget on our upstream-refused error page.
9
9
  *
10
10
  * Two probes:
11
11
  * - probeLocalPort(port): pre-flight TCP connect to 127.0.0.1:<port>
12
- * - probeTunnelHealth(url): HTTP check that traffic actually flows through
13
- * the tunnel to our local server (catches IPv4/IPv6 bind mismatches,
14
- * misconfigured ngrok, etc.)
12
+ * - probeTunnelHealth(url): asks the tunnel server to fetch the tunnel's own
13
+ * public URL and report what it got — proof that traffic actually flows
14
+ * end to end (catches IPv4/IPv6 bind mismatches, a dev server that died
15
+ * after the port probe, a tunnel that never came up).
15
16
  */
16
17
  import { createConnection } from 'node:net';
17
- import { request as httpRequest } from 'node:http';
18
- import { request as httpsRequest } from 'node:https';
19
- import { Readable } from 'node:stream';
20
- import { tunnelDomainFor } from './tunnelDomains.js';
21
18
  import { getControlProbe } from '../services/tunnel/probeRegistry.js';
22
19
  export async function probeLocalPort(port, opts = {}) {
23
20
  const host = opts.host ?? '127.0.0.1';
@@ -58,187 +55,54 @@ export async function probeLocalPort(port, opts = {}) {
58
55
  });
59
56
  }
60
57
  /**
61
- * Bead k6yq: connection-level failures that mean "the edge isn't ready yet",
62
- * NOT "the tunnel is broken".
58
+ * Markers meaning "the edge does not route this hostname".
63
59
  *
64
- * Measured live on freshly created tunnels against a server serving 200: the
65
- * ngrok edge sends an HTTP/2 GOAWAY with code 0 (NO_ERROR) — a graceful
66
- * "reconnect on a fresh connection" signal — which undici raises as
67
- * UND_ERR_SOCKET behind an opaque "fetch failed". DNS resolved in 1ms, and a
68
- * retry 250ms later returned 200. Treating that as a fault reported a healthy
69
- * dev server as unreachable roughly 1 run in 5-14.
70
- *
71
- * Deliberately EXCLUDED (these resolve and answer — they are real faults):
72
- * ECONNREFUSED / EHOSTUNREACH / ENETUNREACH, TLS errors, any HTTP response
73
- * (incl. 502 + ERR_NGROK_*), and timeouts.
74
- */
75
- const TRANSIENT_CAUSE_CODES = new Set([
76
- 'ENOTFOUND', // DNS: name not resolvable yet
77
- 'EAI_AGAIN', // DNS: temporary resolver failure
78
- 'ECONNRESET', // edge dropped the connection mid-handshake
79
- 'EPIPE',
80
- // undici codes. Since bead kmzb the default probe transport is node:https,
81
- // which reports the OS codes above instead — these stay for an injected
82
- // fetchFn, and as the record of what the HTTP/2 path used to produce.
83
- 'UND_ERR_SOCKET',
84
- 'UND_ERR_CONNECT_TIMEOUT',
85
- ]);
86
- /**
87
- * ngrok codes meaning "the edge does not route this hostname".
88
- *
89
- * Kept here deliberately separate from tunnelDisposition's ENDPOINT_GONE
90
- * allowlist even though they currently hold the same code: that list decides
91
- * whether a verdict may DESTROY a tunnel, this one decides whether a verdict is
92
- * worth double-checking first. A code could reasonably be on one and not the
93
- * other, and coupling them would make this module depend on policy it has no
94
- * business knowing.
60
+ * Kept deliberately separate from tunnelDisposition's ENDPOINT_GONE allowlist
61
+ * even though the codes overlap: that list decides whether a verdict may
62
+ * DESTROY a tunnel, this one decides whether a verdict is worth double-checking
63
+ * first. A code could reasonably be on one and not the other, and coupling them
64
+ * would make this module depend on policy it has no business knowing.
95
65
  */
96
66
  const ENDPOINT_NOT_FOUND_CODES = new Set([
97
- 'ERR_NGROK_3200',
98
- // The debugg analogue. The server already holds a browser request for
99
- // RECONNECT_GRACE_MS (5s) while a client reconnects before rendering this,
100
- // but a reconnect that outlasts the grace window would otherwise evict a
101
- // tunnel that is about to come back, so confirm it across the ladder too.
67
+ // The server already holds a browser request for RECONNECT_GRACE_MS (5s)
68
+ // while a client reconnects before rendering this, but a reconnect that
69
+ // outlasts the grace window would otherwise evict a tunnel that is about to
70
+ // come back, so confirm it across the ladder too.
71
+ //
102
72
  // DEBUGG_TUNNEL_UNKNOWN is deliberately NOT here: the id is gone, and
103
73
  // re-probing cannot change that answer.
104
74
  'DEBUGG_TUNNEL_OFFLINE',
105
75
  ]);
106
- // ─ HTTP/1.1 probe transport (bead kmzb) ──────────────────────────────────────
107
- /**
108
- * `fetch`-shaped GET that speaks HTTP/1.1, because the global fetch cannot.
109
- *
110
- * Measured against the ngrok edge on 2026-07-27, Node v26:
111
- *
112
- * ALPN offer [h2, http/1.1] → edge selects h2
113
- * raw HTTP/2 GET of a hostname ngrok no longer routes
114
- * → 404 with ERR_NGROK_3200 in the body, AND a GOAWAY (code 0, NO_ERROR)
115
- * on the same connection
116
- * undici (global fetch) over that h2 connection
117
- * → TypeError: fetch failed / UND_ERR_SOCKET, 3/3 — the response is lost
118
- * to the concurrent GOAWAY and never surfaces
119
- * node:https (HTTP/1.1)
120
- * → 404 + ERR_NGROK_3200, 3/3
121
- *
122
- * So the edge was answering us the whole time and undici was dropping the
123
- * answer. That is why `ngrokErrorCode` had never once been populated in
124
- * production, which in turn made tunnelDisposition's ENDPOINT_GONE allowlist
125
- * unreachable: every probe failure looked like a transient network error, and
126
- * a genuinely dead endpoint could never be told apart from a flake. Speaking
127
- * HTTP/1.1 makes the distinction observable again and re-activates that
128
- * allowlist — so ERR_NGROK_3200 can once more evict a tunnel that is provably
129
- * gone, while everything short of proof still keeps the tunnel we are paying
130
- * for. It also removes the h2 GOAWAY that bead k6yq's retry ladder existed to
131
- * paper over, leaving the ladder as a genuine safety net rather than a
132
- * load-bearing workaround.
133
- *
134
- * Node's fetch offers no way to disable h2, hence a transport rather than an
135
- * option. `http.ClientRequest` — which `https.request` returns — implements
136
- * HTTP/1.x only, so this cannot regress into h2 no matter what a future Node
137
- * negotiates by default. The seam is deliberately `typeof fetch` so every
138
- * caller and test that injects `fetchFn` is unaffected.
139
- */
140
- export const http1Fetch = async (input, init) => {
141
- const url = new URL(typeof input === 'string' ? input : input.url ?? String(input));
142
- const isPlainHttp = url.protocol === 'http:';
143
- const signal = init?.signal ?? undefined;
144
- const options = {
145
- protocol: url.protocol,
146
- host: url.hostname,
147
- port: url.port || (isPlainHttp ? 80 : 443),
148
- path: `${url.pathname}${url.search}`,
149
- method: (init?.method ?? 'GET').toUpperCase(),
150
- headers: init?.headers ?? {},
151
- // One-shot probe — do not leave a pooled keep-alive socket behind.
152
- agent: false,
153
- };
154
- return new Promise((resolve, reject) => {
155
- let settled = false;
156
- const fail = (err) => {
157
- if (settled)
158
- return;
159
- settled = true;
160
- reject(err);
161
- };
162
- const onResponse = (res) => {
163
- if (settled)
164
- return;
165
- settled = true;
166
- const status = res.statusCode ?? 0;
167
- // Response rejects a body on these statuses; they have none anyway.
168
- const bodyless = status === 101 || status === 204 || status === 205 || status === 304;
169
- if (bodyless)
170
- res.resume();
171
- resolve(new Response(bodyless ? null : Readable.toWeb(res), {
172
- status,
173
- headers: headersFrom(res.headers),
174
- }));
175
- };
176
- const req = isPlainHttp
177
- ? httpRequest(options, onResponse)
178
- : httpsRequest(options, onResponse);
179
- if (signal) {
180
- if (signal.aborted) {
181
- req.destroy();
182
- return fail(abortError());
183
- }
184
- signal.addEventListener('abort', () => {
185
- req.destroy();
186
- fail(abortError());
187
- }, { once: true });
188
- }
189
- req.on('error', fail);
190
- req.end();
191
- });
192
- };
193
- /** Matches what an aborted fetch throws, so probeOnce's TIMEOUT arm still fires. */
194
- function abortError() {
195
- const err = new Error('The operation was aborted.');
196
- err.name = 'AbortError';
197
- return err;
198
- }
199
- /** node:http header bag → Headers, skipping the pseudo/array oddities. */
200
- function headersFrom(raw) {
201
- const headers = new Headers();
202
- for (const [key, value] of Object.entries(raw)) {
203
- if (value === undefined)
204
- continue;
205
- for (const v of Array.isArray(value) ? value : [value]) {
206
- try {
207
- headers.append(key, v);
208
- }
209
- catch {
210
- // A header Headers refuses (invalid name from a hostile server) is not
211
- // worth failing a health probe over.
212
- }
213
- }
214
- }
215
- return headers;
216
- }
217
- /** undici hides the real error behind `TypeError: fetch failed` — dig it out. */
218
- function errorCodeOf(err) {
219
- return err?.cause?.code ?? err?.code;
220
- }
221
- function isTransientConnectionError(err) {
222
- const code = errorCodeOf(err);
223
- return code !== undefined && TRANSIENT_CAUSE_CODES.has(code);
224
- }
225
76
  /**
226
77
  * Probe that traffic actually flows through the tunnel.
227
78
  *
228
- * Bead k6yq: a freshly created ngrok tunnel can bounce the first connection
229
- * (HTTP/2 GOAWAY, DNS not yet resolvable) while being perfectly healthy, so
230
- * transient connection-level failures are retried with a short backoff. Every
231
- * other outcome — any HTTP response, connection refused, timeout — is returned
232
- * on the first attempt, so a real fault is never retried into a false pass.
79
+ * ONE transport, ONE mechanism: a tunnel hostname resolves only inside our
80
+ * VPC, so it cannot be fetched from the user's machine at all. The health
81
+ * question goes over the tunnel's own control websocket (PROBE /
82
+ * PROBE_RESULT), and the server answers it by making a REAL request back
83
+ * through the public ingress path — same ALB, same nginx, same routing a
84
+ * browser takes. That is strictly better evidence than the client-side HTTP
85
+ * GET this used to do while tunnel URLs were public ngrok hostnames, which is
86
+ * why the HTTP transport (and its `fetchFn` seam, its HTTP/1.1-forcing
87
+ * `http1Fetch`, and the undici-flake retry codes that went with it) went away
88
+ * with ngrok rather than being kept as a fallback: there is nothing left it
89
+ * could reach.
90
+ *
91
+ * Bead k6yq: a freshly created tunnel can bounce the first probe while being
92
+ * perfectly healthy (a session mid-reconnect), so connection-level failures
93
+ * are retried with a short backoff. Every other outcome — any status the app
94
+ * answered with, a timeout, an unknown id — is returned on the first attempt,
95
+ * so a real fault is never retried into a false pass.
233
96
  */
234
97
  export async function probeTunnelHealth(tunnelUrl, opts = {}) {
235
98
  const maxAttempts = Math.max(1, opts.maxAttempts ?? 3);
236
99
  const backoff = opts.retryBackoffMs ?? [150, 350];
237
100
  const sleep = opts.sleepFn ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
101
+ const timeoutMs = opts.timeoutMs ?? 5000;
238
102
  const started = Date.now();
239
103
  let last;
240
104
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
241
- const { result, retryable } = await probeOnce(tunnelUrl, opts, started);
105
+ const { result, retryable } = await probeOverControlChannel(tunnelUrl, timeoutMs, started);
242
106
  if (!retryable)
243
107
  return result;
244
108
  last = result;
@@ -246,132 +110,15 @@ export async function probeTunnelHealth(tunnelUrl, opts = {}) {
246
110
  await sleep(backoff[attempt - 1] ?? backoff[backoff.length - 1] ?? 150);
247
111
  }
248
112
  }
249
- // Ladder exhausted on a transient error — the edge never came up. Report it:
250
- // "not ready" that never becomes ready IS unhealthy.
113
+ // Ladder exhausted on a transient error — the tunnel never came up. Report
114
+ // it: "not ready" that never becomes ready IS unhealthy.
251
115
  return last;
252
116
  }
253
- async function probeOnce(tunnelUrl, opts, started) {
254
- const timeoutMs = opts.timeoutMs ?? 5000;
255
- // A debugg tunnel hostname resolves only inside our VPC, so it cannot be
256
- // fetched from the user's machine at all. Its health question goes over the
257
- // tunnel's own control websocket instead, and the server answers it by
258
- // making a REAL request through the public ingress path — same ALB, same
259
- // nginx, same routing a browser takes. Everything below this branch (the
260
- // retry ladder, the confirm-across-retries rule, the result shape the
261
- // handlers render) is shared by both transports.
262
- if (tunnelDomainFor(tunnelUrl)?.transport === 'debugg') {
263
- return probeOverControlChannel(tunnelUrl, timeoutMs, started);
264
- }
265
- // Bead kmzb: HTTP/1.1, not the global fetch — see http1Fetch.
266
- const fetchImpl = opts.fetchFn ?? http1Fetch;
267
- const controller = new AbortController();
268
- const timer = setTimeout(() => controller.abort(), timeoutMs);
269
- try {
270
- const res = await fetchImpl(tunnelUrl, {
271
- method: 'GET',
272
- redirect: 'manual',
273
- signal: controller.signal,
274
- // Many user apps reject HEAD; stick to GET for broader compatibility.
275
- headers: { 'User-Agent': 'debugg-ai-mcp/tunnel-health-probe' },
276
- });
277
- clearTimeout(timer);
278
- // Read body so we can inspect for ngrok error markers. Cap at 4KB —
279
- // ngrok error pages are small; a full user app body is a waste.
280
- const bodyText = await readCapped(res, 4096);
281
- const ngrokErr = extractNgrokErrorCode(bodyText);
282
- // An ngrok error page. A RESPONSE is proof the edge is up, so these are
283
- // returned as-is rather than retried into a false pass, and bead 4bui's
284
- // marker-driven reclassification keeps seeing the real verdict.
285
- //
286
- // One exception, and it exists only because bead kmzb made these codes
287
- // reachable at all: ENDPOINT_NOT_FOUND is the single verdict that
288
- // AUTHORISES A TEARDOWN (tunnelDisposition's allowlist), and the ngrok
289
- // agent passes through a short window where it is briefly true — while a
290
- // dropped session reconnects and re-announces the same hostname. Acting on
291
- // one sample would kill a tunnel that was about to come back, at a cost of
292
- // two billed hours. So this code alone is confirmed across the retry
293
- // ladder: a reconnect resolves inside it, a genuinely deleted endpoint
294
- // answers the same way every time and is still reported as gone.
295
- if (ngrokErr) {
296
- const notFound = ENDPOINT_NOT_FOUND_CODES.has(ngrokErr);
297
- return {
298
- retryable: notFound,
299
- result: {
300
- healthy: false,
301
- status: res.status,
302
- code: 'NGROK_ERROR',
303
- ngrokErrorCode: ngrokErr,
304
- detail: notFound
305
- ? `ngrok returned ${ngrokErr} — the edge no longer routes this tunnel hostname`
306
- : `ngrok returned ${ngrokErr} — tunnel established but traffic could not reach dev server`,
307
- elapsedMs: Date.now() - started,
308
- },
309
- };
310
- }
311
- if (res.status === 502 || res.status === 504) {
312
- return {
313
- retryable: false,
314
- result: {
315
- healthy: false,
316
- status: res.status,
317
- code: 'BAD_GATEWAY',
318
- detail: `tunnel returned ${res.status} without an ngrok error marker — gateway is rejecting upstream`,
319
- elapsedMs: Date.now() - started,
320
- },
321
- };
322
- }
323
- // Any other response (incl. 4xx from user's app) means traffic reached
324
- // the dev server — that's healthy from the TUNNEL's perspective. The
325
- // user's 404 is a user concern, not a tunnel concern.
326
- return {
327
- retryable: false,
328
- result: {
329
- healthy: true,
330
- status: res.status,
331
- elapsedMs: Date.now() - started,
332
- },
333
- };
334
- }
335
- catch (err) {
336
- clearTimeout(timer);
337
- const e = err;
338
- if (e?.name === 'AbortError' || /abort|timeout/i.test(e?.message ?? '')) {
339
- // Not retried: a hanging tunnel must not cost 3x the timeout budget.
340
- return {
341
- retryable: false,
342
- result: {
343
- healthy: false,
344
- code: 'TIMEOUT',
345
- detail: `tunnel health probe timed out after ${timeoutMs}ms`,
346
- elapsedMs: Date.now() - started,
347
- },
348
- };
349
- }
350
- // Surface the cause code: undici reports every network failure as the
351
- // undiagnosable "fetch failed" and hides the truth in err.cause.
352
- const causeCode = errorCodeOf(e);
353
- const baseMsg = e?.message ?? String(err);
354
- const causeMsg = e?.cause?.message;
355
- const detail = causeCode
356
- ? `${baseMsg} (${causeCode}${causeMsg && causeMsg !== baseMsg ? `: ${causeMsg}` : ''})`
357
- : baseMsg;
358
- return {
359
- retryable: isTransientConnectionError(e),
360
- result: {
361
- healthy: false,
362
- code: 'NETWORK_ERROR',
363
- detail,
364
- elapsedMs: Date.now() - started,
365
- },
366
- };
367
- }
368
- }
369
- // ─ Control-channel probe (debugg transport) ──────────────────────────────────
117
+ // ─ Control-channel probe ─────────────────────────────────────────────────────
370
118
  /**
371
119
  * Ask the tunnel server to fetch `path` back through this tunnel and report
372
- * what it got. Maps PROBE_RESULT onto the same TunnelHealthProbeResult the
373
- * HTTP path produces — the mapping is pinned on bead debugg_ai_mcp-xkoh.1.2 §9
374
- * and must not be re-derived here.
120
+ * what it got. The PROBE_RESULT → TunnelHealthProbeResult mapping is pinned on
121
+ * bead debugg_ai_mcp-xkoh.1.2 §9 and must not be re-derived here.
375
122
  */
376
123
  async function probeOverControlChannel(tunnelUrl, timeoutMs, started) {
377
124
  const elapsed = () => Date.now() - started;
@@ -384,8 +131,8 @@ async function probeOverControlChannel(tunnelUrl, timeoutMs, started) {
384
131
  retryable: false,
385
132
  result: {
386
133
  healthy: false,
387
- code: 'NGROK_ERROR',
388
- ngrokErrorCode: 'DEBUGG_TUNNEL_UNKNOWN',
134
+ code: 'TUNNEL_ERROR',
135
+ tunnelErrorCode: 'DEBUGG_TUNNEL_UNKNOWN',
389
136
  detail: 'no live debugg tunnel session in this process for this hostname',
390
137
  elapsedMs: elapsed(),
391
138
  },
@@ -416,8 +163,8 @@ async function probeOverControlChannel(tunnelUrl, timeoutMs, started) {
416
163
  result: {
417
164
  healthy: false,
418
165
  status,
419
- code: 'NGROK_ERROR',
420
- ngrokErrorCode: marker,
166
+ code: 'TUNNEL_ERROR',
167
+ tunnelErrorCode: marker,
421
168
  detail: notFound
422
169
  ? `the tunnel server returned ${marker} — no client is connected for this tunnel`
423
170
  : `the tunnel server returned ${marker} — tunnel established but traffic could not reach the dev server`,
@@ -443,8 +190,8 @@ async function probeOverControlChannel(tunnelUrl, timeoutMs, started) {
443
190
  return { retryable: false, result: { healthy: true, status, elapsedMs: elapsed() } };
444
191
  }
445
192
  if (error === 'TIMEOUT') {
446
- // Not retried, for the same reason the HTTP path does not: a hanging
447
- // tunnel must not cost three times the timeout budget.
193
+ // Not retried: a hanging tunnel must not cost three times the timeout
194
+ // budget.
448
195
  return {
449
196
  retryable: false,
450
197
  result: {
@@ -478,62 +225,36 @@ function probePathOf(tunnelUrl) {
478
225
  return '/';
479
226
  }
480
227
  }
481
- // ─ helpers ───────────────────────────────────────────────────────────────────
482
- async function readCapped(res, maxBytes) {
483
- if (!res.body)
484
- return '';
485
- const reader = res.body.getReader();
486
- const decoder = new TextDecoder();
487
- let total = 0;
488
- let out = '';
489
- try {
490
- while (total < maxBytes) {
491
- const { value, done } = await reader.read();
492
- if (done)
493
- break;
494
- const remaining = maxBytes - total;
495
- const chunk = value.length > remaining ? value.slice(0, remaining) : value;
496
- out += decoder.decode(chunk, { stream: true });
497
- total += chunk.length;
498
- if (value.length > remaining) {
499
- // Got enough; cancel the rest.
500
- await reader.cancel().catch(() => { });
501
- break;
502
- }
503
- }
504
- out += decoder.decode();
505
- }
506
- catch {
507
- /* ignore read errors — we return what we have */
508
- }
509
- return out;
510
- }
228
+ // ─ Error markers ─────────────────────────────────────────────────────────────
511
229
  /**
512
230
  * The debugg tunnel server's error markers, spelled out rather than matched by
513
231
  * a `DEBUGG_TUNNEL_[A-Z_]+` pattern.
514
232
  *
515
233
  * A pattern would also match this repo's own `DEBUGG_TUNNEL_FAULT_MODE` env var
516
- * (services/ngrok/tunnelFaultInjection.ts). This function is used on RUN
234
+ * (services/tunnel/tunnelFaultInjection.ts). This function is used on RUN
517
235
  * EVIDENCE as well as on response bodies (testPageChangesHandler's
518
- * findNgrokErrorMarker), so a log line mentioning that variable would otherwise
236
+ * findTunnelErrorMarker), so a log line mentioning that variable would otherwise
519
237
  * be read as proof that the browser hit our error page, and a genuine UI
520
238
  * failure would be reclassified as an infrastructure fault.
239
+ *
240
+ * ngrok's own error codes used to be matched here too. They went with the
241
+ * ngrok transport: no tunnel this client can create is served by ngrok, so
242
+ * matching them would be dead logic that reads as live — and worse, it would
243
+ * reclassify a genuine app failure as an infrastructure fault if a user's own
244
+ * page ever mentioned one.
521
245
  */
522
246
  const DEBUGG_TUNNEL_MARKERS = Object.freeze([
523
247
  'DEBUGG_TUNNEL_OFFLINE',
524
248
  'DEBUGG_TUNNEL_UNKNOWN',
525
249
  'DEBUGG_TUNNEL_UPSTREAM_REFUSED',
526
250
  ]);
527
- const MARKER_PATTERN = new RegExp(`ERR_NGROK_\\d+|${DEBUGG_TUNNEL_MARKERS.join('|')}`);
251
+ const MARKER_PATTERN = new RegExp(DEBUGG_TUNNEL_MARKERS.join('|'));
528
252
  /**
529
- * The stable error marker a tunnel put in front of the user's app, from either
530
- * transport: ngrok's `ERR_NGROK_*` or the debugg server's `DEBUGG_TUNNEL_*`.
531
- *
532
- * Name kept (and with it `TunnelHealthProbeResult.ngrokErrorCode` and
533
- * `code: 'NGROK_ERROR'`) because handlers echo these into tool output;
534
- * renaming them is part of deleting ngrok, not of adding a transport.
253
+ * The stable error marker the tunnel server put in front of the user's app,
254
+ * if any. Handlers echo it into tool output, so it is the one piece of
255
+ * evidence that distinguishes "our tunnel broke" from "the app failed".
535
256
  */
536
- export function extractNgrokErrorCode(body) {
257
+ export function extractTunnelErrorCode(body) {
537
258
  const match = body.match(MARKER_PATTERN);
538
259
  return match ? match[0] : undefined;
539
260
  }
@@ -56,15 +56,15 @@ export const TelemetryEvents = {
56
56
  TUNNEL_PROVISIONED: 'tunnel.provisioned',
57
57
  TUNNEL_PROVISION_RETRY: 'tunnel.provision_retry',
58
58
  TUNNEL_STOPPED: 'tunnel.stopped',
59
- // services/ngrok/tunnelManager.ts — the session tunnel's Caddy instance
59
+ // services/tunnel/tunnelManager.ts — the session tunnel's Caddy instance
60
60
  // crash-respawned onto a DIFFERENT local proxy port (sticky-port reclaim
61
- // failed). The existing ngrok tunnel is now dialing a dead port, so the
61
+ // failed). The existing tunnel is now dialing a dead port, so the
62
62
  // whole session tunnel is evicted and must be recreated on the next call
63
63
  // (see docs/local-tunnel-multiplexer-architecture-2026-07-31.md §2.3).
64
64
  TUNNEL_EVICTED_PORT_CHANGED: 'tunnel.evicted_port_changed',
65
- // services/ngrok/tunnelManager.ts — stopTunnel()'s unconditional-removal
65
+ // services/tunnel/tunnelManager.ts — stopTunnel()'s unconditional-removal
66
66
  // contract (§2.3): map state is always removed before cleanup I/O runs, so
67
- // a partial failure here (ngrok disconnect / caddy.stop() / key revoke)
67
+ // a partial failure here (tunnel disconnect / caddy.stop() / key revoke)
68
68
  // never leaves stale-but-discoverable state. Fired once per failed step.
69
69
  TUNNEL_TEARDOWN_PARTIAL_FAILURE: 'tunnel.teardown_partial_failure',
70
70
  TEMPLATE_LOOKUP: 'template.lookup',
@@ -3,11 +3,11 @@
3
3
  *
4
4
  * Centralizes:
5
5
  * - resolving user input url to a concrete URL
6
- * - creating / reusing ngrok tunnels after the backend returns a tunnelKey
6
+ * - creating / reusing tunnels after the backend returns a tunnelKey
7
7
  * - acquiring/releasing this session's shared Caddy port route (§2.4)
8
8
  * - sanitizing backend responses so callers only ever see the original URL
9
9
  */
10
- import { tunnelManager, getSessionKey } from '../services/ngrok/tunnelManager.js';
10
+ import { tunnelManager, getSessionKey } from '../services/tunnel/tunnelManager.js';
11
11
  import { isLocalhostUrl, replaceTunnelUrls, retargetTunnelUrl, extractLocalhostPort } from './urlParser.js';
12
12
  // ─── URL resolution ──────────────────────────────────────────────────────────
13
13
  /**
@@ -28,7 +28,7 @@ export function buildContext(originalUrl) {
28
28
  }
29
29
  // ─── Tunnel creation ─────────────────────────────────────────────────────────
30
30
  /**
31
- * Check whether this SESSION already has a tunnel (§2.1 — one ngrok tunnel
31
+ * Check whether this SESSION already has a tunnel (§2.1 — one tunnel
32
32
  * per session key, not per local port). If found, touches its timer and
33
33
  * returns an enriched context retargeted at this caller's own path. Returns
34
34
  * null for public URLs or when this session has no tunnel yet.
@@ -57,14 +57,16 @@ export function findExistingTunnel(ctx) {
57
57
  * No-op for public URLs.
58
58
  *
59
59
  * @param ctx - Context built from `buildContext()`
60
- * @param tunnelKey - Auth token from the backend (short-lived ngrok key)
61
- * @param tunnelId - ID to use as the ngrok subdomain (only takes effect the
62
- * first time this session creates a tunnel)
60
+ * @param tunnelKey - Auth token from the backend (short-lived tunnel token)
61
+ * @param tunnelId - ID to use as the tunnel hostname's label (only takes
62
+ * effect the first time this session creates a tunnel).
63
+ * The token is bound to it, so it must be the backend's.
63
64
  * @param keyId - Backend key ID; stored on the tunnel so it is revoked on stop
64
65
  * @param revokeKey - Callback that revokes the backend tunnel (called when tunnel stops)
65
- * @param selection - The provision response's transport fields. Omitted means
66
- * ngrok, which is what an old backend implies, so every
67
- * existing caller keeps its meaning.
66
+ * @param selection - The provision response's relayUrl / tunnelDomain /
67
+ * tunnelId. Omitting it leaves the transport with nowhere
68
+ * to connect, so every handler must pass the provision
69
+ * (locked by __tests__/handlers/tunnelSelectionWiring.test.ts).
68
70
  */
69
71
  export async function ensureTunnel(ctx, tunnelKey, tunnelId, keyId, revokeKey, selection) {
70
72
  if (!ctx.isLocalhost)