@debugg-ai/debugg-ai-mcp 4.3.0 → 4.4.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.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Debugg tunnel wire protocol v1 — one multiplexed stream as a node Duplex.
3
+ *
4
+ * A TunnelStream is the byte pipe for exactly one inbound browser connection.
5
+ * Both ends pipe it straight at a socket, so being a real Duplex (and honouring
6
+ * backpressure in both directions) is the whole point:
7
+ *
8
+ * server: socket.pipe(stream).pipe(socket)
9
+ * client: stream.pipe(net.connect(caddyLoopbackPort)).pipe(stream)
10
+ *
11
+ * Writes become DATA frames of at most MAX_DATA_PAYLOAD, gated by the stream's
12
+ * remaining credit. end() sends END. destroy() sends RST.
13
+ *
14
+ * All protocol state lives in the session; this class only forwards. The one
15
+ * piece of cleverness is the read() override, which is how the session learns
16
+ * how many bytes the application has actually consumed and therefore how much
17
+ * credit it may give back.
18
+ */
19
+ import { Duplex } from 'node:stream';
20
+ import { ErrorCode, MAX_DATA_PAYLOAD } from './constants.js';
21
+ export class TunnelStream extends Duplex {
22
+ /** The stream id the server allocated. */
23
+ id;
24
+ /**
25
+ * Whitelisted OPEN metadata. Informational only — NEVER a dial target
26
+ * (see the SSRF note on bead debugg_ai_mcp-xkoh.1.2).
27
+ */
28
+ metadata;
29
+ host;
30
+ pendingResetCode;
31
+ pushedTotal = 0;
32
+ reportedConsumed = 0;
33
+ constructor(options) {
34
+ super({
35
+ // One frame's worth of writable buffer: enough to keep the wire busy,
36
+ // little enough that a piped producer is held back rather than drained.
37
+ writableHighWaterMark: MAX_DATA_PAYLOAD,
38
+ readableHighWaterMark: MAX_DATA_PAYLOAD,
39
+ allowHalfOpen: true,
40
+ });
41
+ this.id = options.id;
42
+ this.metadata = options.metadata;
43
+ this.host = options.host;
44
+ }
45
+ /**
46
+ * Abort both directions with RST(code). Defaults to ErrorCode.RESET. Use
47
+ * ErrorCode.UPSTREAM_UNREACHABLE when the local target could not be dialled,
48
+ * so the server can render DEBUGG_TUNNEL_UPSTREAM_REFUSED.
49
+ *
50
+ * Deliberately does not raise a local 'error': the side that chose to reset
51
+ * already knows, and an unhandled 'error' on a Duplex throws.
52
+ */
53
+ reset(code = ErrorCode.RESET) {
54
+ this.pendingResetCode = code;
55
+ this.destroy();
56
+ }
57
+ /**
58
+ * Hand the session's inbound bytes to the readable side.
59
+ *
60
+ * Never call push() directly: in flowing mode node delivers a pushed chunk
61
+ * straight to the 'data' listener without it ever entering the buffer or going
62
+ * through read(), so consumption has to be reconciled right here. Missing it
63
+ * means credit is never returned and a transfer larger than one window
64
+ * deadlocks — which is exactly what it did.
65
+ */
66
+ deliver(chunk) {
67
+ this.pushedTotal += chunk.length;
68
+ const accepted = this.push(chunk);
69
+ this.syncConsumed();
70
+ return accepted;
71
+ }
72
+ /** The peer half-closed: no more inbound bytes on this stream. */
73
+ deliverEnd() {
74
+ this.push(null);
75
+ }
76
+ /**
77
+ * Bytes that have left the readable buffer are bytes the application consumed,
78
+ * and those are what the session may grant back as WINDOW. Credit is never
79
+ * given for bytes still sitting in the buffer.
80
+ */
81
+ syncConsumed() {
82
+ const consumed = this.pushedTotal - this.readableLength;
83
+ const delta = consumed - this.reportedConsumed;
84
+ if (delta <= 0)
85
+ return;
86
+ this.reportedConsumed = consumed;
87
+ this.host.streamConsumed(this, delta);
88
+ }
89
+ read(size) {
90
+ const chunk = super.read(size);
91
+ this.syncConsumed();
92
+ return chunk;
93
+ }
94
+ _read() {
95
+ // Nothing to pull: the session pushes as DATA frames arrive, and flow
96
+ // control — not this callback — is what limits how much can be in flight.
97
+ }
98
+ _write(chunk, encoding, cb) {
99
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
100
+ this.host.streamWrite(this, buf, cb);
101
+ }
102
+ _final(cb) {
103
+ this.host.streamFinal(this, cb);
104
+ }
105
+ _destroy(err, cb) {
106
+ this.host.streamDestroy(this, err, this.pendingResetCode);
107
+ cb(err);
108
+ }
109
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Debugg tunnel wire protocol v1 — transport seam.
3
+ *
4
+ * TunnelSession never touches a websocket directly. It talks to a FrameTransport,
5
+ * which is what lets the unit tests drive a session frame by frame (and with fake
6
+ * timers) while the integration tests run the same session over a real `ws`
7
+ * socket. `ws` is referenced only structurally here, so this module keeps its
8
+ * "node: built-ins only" property and stays embeddable in the tunnel server.
9
+ */
10
+ /** RFC 6455: the connection dropped without a close handshake. */
11
+ const ABNORMAL_CLOSURE = 1006;
12
+ /** ws hands out Buffer, ArrayBuffer, or an array of Buffers for a fragmented message. */
13
+ function toBytes(data) {
14
+ if (Buffer.isBuffer(data))
15
+ return data;
16
+ if (data instanceof ArrayBuffer)
17
+ return new Uint8Array(data);
18
+ if (ArrayBuffer.isView(data))
19
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
20
+ if (Array.isArray(data) && data.every((part) => Buffer.isBuffer(part)))
21
+ return Buffer.concat(data);
22
+ return undefined;
23
+ }
24
+ function reasonToString(reason) {
25
+ if (typeof reason === 'string')
26
+ return reason;
27
+ if (Buffer.isBuffer(reason))
28
+ return reason.toString('utf8');
29
+ if (reason instanceof ArrayBuffer)
30
+ return Buffer.from(reason).toString('utf8');
31
+ return '';
32
+ }
33
+ /**
34
+ * Adapt a ws WebSocket (client or server side) to a FrameTransport.
35
+ *
36
+ * Responsibilities: normalise inbound data to a Uint8Array, reject text messages
37
+ * as invalid, and make sure the session is told exactly once that the socket is
38
+ * gone — whichever of 'close' and 'error' happens to fire, and in whichever
39
+ * order.
40
+ */
41
+ export function webSocketTransport(ws) {
42
+ let handlers;
43
+ let closed = false;
44
+ let socketError;
45
+ const reportClose = (code, reason) => {
46
+ if (closed)
47
+ return;
48
+ closed = true;
49
+ handlers?.onClose(code, reason);
50
+ };
51
+ return {
52
+ send(frame, cb) {
53
+ if (closed) {
54
+ cb(new Error('websocket is closed'));
55
+ return;
56
+ }
57
+ try {
58
+ ws.send(frame, (err) => cb(err ?? null));
59
+ }
60
+ catch (err) {
61
+ cb(err);
62
+ }
63
+ },
64
+ close(code, reason) {
65
+ try {
66
+ ws.close(code, reason);
67
+ }
68
+ catch {
69
+ /* already closing; the close event still settles the session */
70
+ }
71
+ },
72
+ terminate() {
73
+ try {
74
+ ws.terminate();
75
+ }
76
+ catch {
77
+ /* nothing left to tear down */
78
+ }
79
+ },
80
+ setHandlers(next) {
81
+ handlers = next;
82
+ ws.on('message', (data, isBinary) => {
83
+ if (closed)
84
+ return;
85
+ if (!isBinary) {
86
+ next.onInvalidMessage('text message');
87
+ return;
88
+ }
89
+ const bytes = toBytes(data);
90
+ if (!bytes) {
91
+ next.onInvalidMessage('message was not binary data');
92
+ return;
93
+ }
94
+ next.onFrame(bytes);
95
+ });
96
+ ws.on('error', (err) => {
97
+ // ws emits 'close' after 'error' for a live socket, so hold the reason
98
+ // and let the close path report it — unless no close ever arrives.
99
+ socketError = err;
100
+ });
101
+ ws.on('close', (code, reason) => {
102
+ reportClose(code || ABNORMAL_CLOSURE, reasonToString(reason) || socketError?.message || '');
103
+ });
104
+ },
105
+ };
106
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * TunnelTransport — the one step of tunnel creation that differs between ngrok
3
+ * and the debugg tunnel server.
4
+ *
5
+ * STUB (bead debugg_ai_mcp-xkoh.5.3, phase 2.1). Types and signatures only; the
6
+ * implementations arrive in phase 4.1 (debugg_ai_mcp-xkoh.5.7).
7
+ *
8
+ * Everything above this seam is unchanged: handlers, utils/tunnelContext.ts,
9
+ * the per-session Caddy instance, PortLock, URL rewriting, the retry ladder,
10
+ * the idle timer and the session keying all behave exactly as they do today
11
+ * (docs/debugg-tunnel-server-design-2026-09-19.md §5). TunnelManager picks an
12
+ * implementation from the provision response and calls it where it calls
13
+ * ngrok.connect() today.
14
+ */
15
+ export {};
@@ -4,6 +4,7 @@
4
4
  * Called before executeWorkflow so the tunnel URL is known before execution starts.
5
5
  */
6
6
  import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
7
+ import { isValidTunnelDomain, registerTunnelDomain, } from '../utils/tunnelDomains.js';
7
8
  /**
8
9
  * Typed error thrown by provision() when the backend/ngrok path fails.
9
10
  * Carries diagnostic fields a retry wrapper (bead 7nx) can use to decide
@@ -80,11 +81,29 @@ export function classifyProvisionError(err) {
80
81
  }
81
82
  const DEFAULT_BACKOFF_MS = [500, 1500, 3000];
82
83
  const DEFAULT_MAX_ATTEMPTS = 3;
84
+ /** The transports this client can actually drive, best first. */
85
+ export const SUPPORTED_TRANSPORTS = Object.freeze(['debugg', 'ngrok']);
86
+ /** A relay URL must be wss, except against a loopback backend (local dev, tests). */
87
+ function isAcceptableRelayUrl(relayUrl) {
88
+ let url;
89
+ try {
90
+ url = new URL(relayUrl);
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ if (url.protocol === 'wss:')
96
+ return true;
97
+ if (url.protocol !== 'ws:')
98
+ return false;
99
+ const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
100
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1';
101
+ }
83
102
  export const createTunnelsService = (tx) => {
84
103
  async function provision(purpose = 'workflow') {
85
104
  let response;
86
105
  try {
87
- response = await tx.post('api/v1/tunnels/', { purpose });
106
+ response = await tx.post('api/v1/tunnels/', { purpose, transports: [...SUPPORTED_TRANSPORTS] });
88
107
  }
89
108
  catch (err) {
90
109
  throw classifyProvisionError(err);
@@ -95,12 +114,61 @@ export const createTunnelsService = (tx) => {
95
114
  retryable: false,
96
115
  });
97
116
  }
98
- return {
117
+ // No `transport` means an old backend that predates negotiation, which only
118
+ // ever hands out ngrok keys.
119
+ const transport = (response.transport ?? 'ngrok');
120
+ if (!SUPPORTED_TRANSPORTS.includes(transport)) {
121
+ throw new TunnelProvisionError({
122
+ message: `Tunnel provisioning selected transport "${response.transport}", which this client cannot speak ` +
123
+ `(it offered ${SUPPORTED_TRANSPORTS.join(', ')}). Update @debugg-ai/debugg-ai-mcp.`,
124
+ retryable: false,
125
+ });
126
+ }
127
+ const result = {
99
128
  tunnelId: response.tunnelId,
100
129
  tunnelKey: response.tunnelKey,
101
130
  keyId: response.keyId,
102
131
  expiresAt: response.expiresAt,
132
+ transport,
103
133
  };
134
+ if (transport === 'debugg') {
135
+ // Every one of these is non-retryable: retrying cannot turn a malformed
136
+ // response into a usable one, and TunnelManager must never be handed a
137
+ // debugg token with nowhere to send it.
138
+ if (!response.relayUrl || !response.tunnelDomain) {
139
+ throw new TunnelProvisionError({
140
+ message: 'Tunnel provisioning selected the debugg transport but omitted relayUrl or tunnelDomain',
141
+ retryable: false,
142
+ });
143
+ }
144
+ if (!isAcceptableRelayUrl(response.relayUrl)) {
145
+ throw new TunnelProvisionError({
146
+ message: `Tunnel provisioning returned a relayUrl that is not wss (${response.relayUrl}); ` +
147
+ 'refusing to send the tunnel key over it',
148
+ retryable: false,
149
+ });
150
+ }
151
+ if (!isValidTunnelDomain(response.tunnelDomain)) {
152
+ throw new TunnelProvisionError({
153
+ message: `Tunnel provisioning returned an unusable tunnelDomain (${response.tunnelDomain})`,
154
+ retryable: false,
155
+ });
156
+ }
157
+ // Registering it here is what stops a tunnel URL leaking: everything that
158
+ // recognises or rewrites a tunnel hostname reads this same registry, so a
159
+ // domain the backend moves to is covered the moment it is handed to us.
160
+ registerTunnelDomain(response.tunnelDomain, 'debugg');
161
+ result.relayUrl = response.relayUrl;
162
+ result.tunnelDomain = response.tunnelDomain;
163
+ }
164
+ return result;
165
+ }
166
+ async function revoke(provisionInfo) {
167
+ if (provisionInfo.transport === 'debugg') {
168
+ await tx.post(`api/v1/tunnels/${provisionInfo.tunnelId}/revoke/`, {});
169
+ return;
170
+ }
171
+ await tx.post('api/v1/ngrok/revoke/', { ngrokKeyId: provisionInfo.keyId });
104
172
  }
105
173
  async function provisionWithRetry(opts = {}) {
106
174
  const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
@@ -144,5 +212,5 @@ export const createTunnelsService = (tx) => {
144
212
  retryable: false,
145
213
  });
146
214
  }
147
- return { provision, provisionWithRetry };
215
+ return { provision, provisionWithRetry, revoke };
148
216
  };
@@ -17,6 +17,8 @@ import { createConnection } from 'node:net';
17
17
  import { request as httpRequest } from 'node:http';
18
18
  import { request as httpsRequest } from 'node:https';
19
19
  import { Readable } from 'node:stream';
20
+ import { tunnelDomainFor } from './tunnelDomains.js';
21
+ import { getControlProbe } from '../services/tunnel/probeRegistry.js';
20
22
  export async function probeLocalPort(port, opts = {}) {
21
23
  const host = opts.host ?? '127.0.0.1';
22
24
  const timeoutMs = opts.timeoutMs ?? 1500;
@@ -91,7 +93,16 @@ const TRANSIENT_CAUSE_CODES = new Set([
91
93
  * other, and coupling them would make this module depend on policy it has no
92
94
  * business knowing.
93
95
  */
94
- const ENDPOINT_NOT_FOUND_CODES = new Set(['ERR_NGROK_3200']);
96
+ 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.
102
+ // DEBUGG_TUNNEL_UNKNOWN is deliberately NOT here: the id is gone, and
103
+ // re-probing cannot change that answer.
104
+ 'DEBUGG_TUNNEL_OFFLINE',
105
+ ]);
95
106
  // ─ HTTP/1.1 probe transport (bead kmzb) ──────────────────────────────────────
96
107
  /**
97
108
  * `fetch`-shaped GET that speaks HTTP/1.1, because the global fetch cannot.
@@ -241,6 +252,16 @@ export async function probeTunnelHealth(tunnelUrl, opts = {}) {
241
252
  }
242
253
  async function probeOnce(tunnelUrl, opts, started) {
243
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
+ }
244
265
  // Bead kmzb: HTTP/1.1, not the global fetch — see http1Fetch.
245
266
  const fetchImpl = opts.fetchFn ?? http1Fetch;
246
267
  const controller = new AbortController();
@@ -345,6 +366,118 @@ async function probeOnce(tunnelUrl, opts, started) {
345
366
  };
346
367
  }
347
368
  }
369
+ // ─ Control-channel probe (debugg transport) ──────────────────────────────────
370
+ /**
371
+ * 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.
375
+ */
376
+ async function probeOverControlChannel(tunnelUrl, timeoutMs, started) {
377
+ const elapsed = () => Date.now() - started;
378
+ const probe = getControlProbe(tunnelUrl);
379
+ if (!probe) {
380
+ // No live control channel in this process for that hostname, and the URL
381
+ // is unreachable any other way. The tunnel is not coming back on its own,
382
+ // so report it gone and let the disposition policy clear the leftovers.
383
+ return {
384
+ retryable: false,
385
+ result: {
386
+ healthy: false,
387
+ code: 'NGROK_ERROR',
388
+ ngrokErrorCode: 'DEBUGG_TUNNEL_UNKNOWN',
389
+ detail: 'no live debugg tunnel session in this process for this hostname',
390
+ elapsedMs: elapsed(),
391
+ },
392
+ };
393
+ }
394
+ let probeResult;
395
+ try {
396
+ probeResult = await probe(probePathOf(tunnelUrl), { timeoutMs });
397
+ }
398
+ catch (err) {
399
+ // The probe API's contract is that it never throws. Belt and braces: a
400
+ // probe we could not run is not evidence of a fault.
401
+ return {
402
+ retryable: true,
403
+ result: {
404
+ healthy: false,
405
+ code: 'NETWORK_ERROR',
406
+ detail: err instanceof Error ? err.message : String(err),
407
+ elapsedMs: elapsed(),
408
+ },
409
+ };
410
+ }
411
+ const { status, marker, error } = probeResult;
412
+ if (marker) {
413
+ const notFound = ENDPOINT_NOT_FOUND_CODES.has(marker);
414
+ return {
415
+ retryable: notFound,
416
+ result: {
417
+ healthy: false,
418
+ status,
419
+ code: 'NGROK_ERROR',
420
+ ngrokErrorCode: marker,
421
+ detail: notFound
422
+ ? `the tunnel server returned ${marker} — no client is connected for this tunnel`
423
+ : `the tunnel server returned ${marker} — tunnel established but traffic could not reach the dev server`,
424
+ elapsedMs: elapsed(),
425
+ },
426
+ };
427
+ }
428
+ if (status !== undefined) {
429
+ if (status === 502 || status === 504) {
430
+ return {
431
+ retryable: false,
432
+ result: {
433
+ healthy: false,
434
+ status,
435
+ code: 'BAD_GATEWAY',
436
+ detail: `tunnel returned ${status} without an error marker — gateway is rejecting upstream`,
437
+ elapsedMs: elapsed(),
438
+ },
439
+ };
440
+ }
441
+ // Any other status means traffic reached the dev server, which is healthy
442
+ // from the TUNNEL's point of view. A user's own 404 is a user concern.
443
+ return { retryable: false, result: { healthy: true, status, elapsedMs: elapsed() } };
444
+ }
445
+ 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.
448
+ return {
449
+ retryable: false,
450
+ result: {
451
+ healthy: false,
452
+ code: 'TIMEOUT',
453
+ detail: `tunnel health probe timed out after ${timeoutMs}ms`,
454
+ elapsedMs: elapsed(),
455
+ },
456
+ };
457
+ }
458
+ // Anything else (RESET, CLOSED, ...) is a connection-level failure. It is
459
+ // retried, because a session that is mid-reconnect reports exactly this and
460
+ // recovers within the ladder.
461
+ return {
462
+ retryable: true,
463
+ result: {
464
+ healthy: false,
465
+ code: 'NETWORK_ERROR',
466
+ detail: `tunnel control channel reported ${error ?? 'an unknown failure'}`,
467
+ elapsedMs: elapsed(),
468
+ },
469
+ };
470
+ }
471
+ /** The path (with search) a probe should request, defaulting to root. */
472
+ function probePathOf(tunnelUrl) {
473
+ try {
474
+ const url = new URL(tunnelUrl);
475
+ return `${url.pathname}${url.search}` || '/';
476
+ }
477
+ catch {
478
+ return '/';
479
+ }
480
+ }
348
481
  // ─ helpers ───────────────────────────────────────────────────────────────────
349
482
  async function readCapped(res, maxBytes) {
350
483
  if (!res.body)
@@ -375,8 +508,32 @@ async function readCapped(res, maxBytes) {
375
508
  }
376
509
  return out;
377
510
  }
511
+ /**
512
+ * The debugg tunnel server's error markers, spelled out rather than matched by
513
+ * a `DEBUGG_TUNNEL_[A-Z_]+` pattern.
514
+ *
515
+ * 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
517
+ * EVIDENCE as well as on response bodies (testPageChangesHandler's
518
+ * findNgrokErrorMarker), so a log line mentioning that variable would otherwise
519
+ * be read as proof that the browser hit our error page, and a genuine UI
520
+ * failure would be reclassified as an infrastructure fault.
521
+ */
522
+ const DEBUGG_TUNNEL_MARKERS = Object.freeze([
523
+ 'DEBUGG_TUNNEL_OFFLINE',
524
+ 'DEBUGG_TUNNEL_UNKNOWN',
525
+ 'DEBUGG_TUNNEL_UPSTREAM_REFUSED',
526
+ ]);
527
+ const MARKER_PATTERN = new RegExp(`ERR_NGROK_\\d+|${DEBUGG_TUNNEL_MARKERS.join('|')}`);
528
+ /**
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.
535
+ */
378
536
  export function extractNgrokErrorCode(body) {
379
- // ngrok error pages surface codes like "ERR_NGROK_8012", "ERR_NGROK_3200", etc.
380
- const match = body.match(/ERR_NGROK_\d+/);
537
+ const match = body.match(MARKER_PATTERN);
381
538
  return match ? match[0] : undefined;
382
539
  }
@@ -61,12 +61,15 @@ export function findExistingTunnel(ctx) {
61
61
  * @param tunnelId - ID to use as the ngrok subdomain (only takes effect the
62
62
  * first time this session creates a tunnel)
63
63
  * @param keyId - Backend key ID; stored on the tunnel so it is revoked on stop
64
- * @param revokeKey - Callback that revokes the backend key (called when tunnel stops)
64
+ * @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.
65
68
  */
66
- export async function ensureTunnel(ctx, tunnelKey, tunnelId, keyId, revokeKey) {
69
+ export async function ensureTunnel(ctx, tunnelKey, tunnelId, keyId, revokeKey, selection) {
67
70
  if (!ctx.isLocalhost)
68
71
  return ctx;
69
- const info = await tunnelManager.ensureSessionTunnel(getSessionKey(), tunnelKey, tunnelId, keyId, revokeKey);
72
+ const info = await tunnelManager.ensureSessionTunnel(getSessionKey(), tunnelKey, tunnelId, keyId, revokeKey, selection);
70
73
  return { ...ctx, tunnelId: info.tunnelId, targetUrl: retargetTunnelUrl(info.tunnelUrl, ctx.originalUrl) };
71
74
  }
72
75
  /**
@@ -72,7 +72,22 @@ const logger = new Logger({ module: 'tunnelDisposition' });
72
72
  * a "frozen" Set still succeeds silently. Freezing the array is a real runtime
73
73
  * guarantee; the lookup Set is derived from it and kept private.
74
74
  */
75
- const ENDPOINT_GONE_CODES = Object.freeze(['ERR_NGROK_3200']);
75
+ const ENDPOINT_GONE_CODES = Object.freeze([
76
+ 'ERR_NGROK_3200',
77
+ // The debugg tunnel server's equivalents (bead debugg_ai_mcp-xkoh.1.2 §10).
78
+ // Both are served BY THE EDGE ABOUT A HOSTNAME IT DOES NOT ROUTE, which is
79
+ // the inclusion criterion above:
80
+ // DEBUGG_TUNNEL_OFFLINE — no client is connected. Confirmed across
81
+ // probeTunnelHealth's retry ladder before it gets here, because the
82
+ // server renders it after a 5s grace window that a slow reconnect can
83
+ // outlast.
84
+ // DEBUGG_TUNNEL_UNKNOWN — unknown or revoked tunnel id. Definitive.
85
+ // DEBUGG_TUNNEL_UPSTREAM_REFUSED is deliberately absent: like ERR_NGROK_8012
86
+ // it means the TUNNEL served us that page and is alive, while the user's dev
87
+ // server is what refused.
88
+ 'DEBUGG_TUNNEL_OFFLINE',
89
+ 'DEBUGG_TUNNEL_UNKNOWN',
90
+ ]);
76
91
  const ENDPOINT_GONE_LOOKUP = new Set(ENDPOINT_GONE_CODES);
77
92
  /** The allowlist, as an immutable list. Read-only by construction. */
78
93
  export const ENDPOINT_GONE_NGROK_CODES = ENDPOINT_GONE_CODES;