@debugg-ai/debugg-ai-mcp 4.4.1 → 6.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +32 -14
  3. package/dist/handlers/createEnvironmentHandler.js +4 -4
  4. package/dist/handlers/createProjectHandler.js +1 -1
  5. package/dist/handlers/environmentSessionsHandler.js +2 -18
  6. package/dist/handlers/probePageHandler.js +10 -13
  7. package/dist/handlers/runTestSuiteHandler.js +8 -9
  8. package/dist/handlers/searchEnvironmentsHandler.js +3 -6
  9. package/dist/handlers/searchExecutionsHandler.js +3 -11
  10. package/dist/handlers/searchProjectsHandler.js +1 -6
  11. package/dist/handlers/testPageChangesHandler.js +88 -183
  12. package/dist/handlers/triggerCrawlHandler.js +52 -44
  13. package/dist/handlers/updateEnvironmentHandler.js +2 -2
  14. package/dist/index.js +1 -1
  15. package/dist/services/caddy/caddyProxy.js +7 -6
  16. package/dist/services/index.js +18 -21
  17. package/dist/services/tunnel/debuggTransport.js +9 -9
  18. package/dist/services/tunnel/protocol/constants.js +3 -3
  19. package/dist/services/tunnel/transport.js +28 -9
  20. package/dist/services/{ngrok → tunnel}/tunnelFaultInjection.js +9 -15
  21. package/dist/services/{ngrok → tunnel}/tunnelManager.js +65 -131
  22. package/dist/services/{ngrok → tunnel}/tunnelRegistry.js +2 -2
  23. package/dist/services/tunnels.js +64 -46
  24. package/dist/services/verdictAdapter.js +167 -41
  25. package/dist/services/workflows.js +10 -1
  26. package/dist/tools/testPageChanges.js +1 -1
  27. package/dist/utils/authorizedCredentialHosts.js +8 -20
  28. package/dist/utils/confirmDestructive.js +1 -2
  29. package/dist/utils/errors.js +4 -5
  30. package/dist/utils/imageUtils.js +2 -2
  31. package/dist/utils/localReachability.js +62 -341
  32. package/dist/utils/resolveProject.js +1 -1
  33. package/dist/utils/telemetry.js +4 -4
  34. package/dist/utils/tunnelContext.js +11 -9
  35. package/dist/utils/tunnelDisposition.js +51 -69
  36. package/dist/utils/tunnelDomains.js +30 -21
  37. package/dist/utils/urlParser.js +38 -6
  38. package/package.json +2 -3
  39. package/dist/services/ngrok/error.js +0 -3
  40. package/dist/services/ngrok/ngrokAgentInspector.js +0 -210
  41. package/dist/services/ngrok/ngrokAgentSession.js +0 -49
  42. package/dist/services/ngrok/types.js +0 -1
  43. package/dist/services/tunnel/ngrokTransport.js +0 -165
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Tunnel Management Service
3
3
  *
4
- * ONE ngrok tunnel per SESSION KEY (§2.1 of
4
+ * ONE tunnel per SESSION KEY (§2.1 of
5
5
  * docs/local-tunnel-multiplexer-architecture-2026-07-31.md) — not one per
6
6
  * local port. A session's tunnel dials a local Caddy instance
7
7
  * (services/caddy/caddyProxy.ts) that holds exactly one dynamic upstream,
@@ -11,10 +11,10 @@
11
11
  *
12
12
  * This retires the entire cross-process "borrow another MCP's tunnel"
13
13
  * mechanism the previous per-port design needed (registry-mediated adoption,
14
- * PID-liveness/freshness checks, re-adoption from the local ngrok agent) —
14
+ * PID-liveness/freshness checks, re-adoption from a local agent) —
15
15
  * see §4's "Same-machine multi-process / multi-session sharing" decision:
16
16
  * "No sharing, ever, at any granularity." Every tunnel this process holds is
17
- * one it created; `services/ngrok/tunnelRegistry.ts` is now write-mostly
17
+ * one it created; `services/tunnel/tunnelRegistry.ts` is now write-mostly
18
18
  * observability, not a correctness dependency.
19
19
  *
20
20
  * Session identity (§2.1): stdio has exactly one session key for its whole
@@ -40,15 +40,16 @@ import { FaultInjector, TunnelTrace, getFaultModeFromEnv } from './tunnelFaultIn
40
40
  import { getDefaultRegistry, } from './tunnelRegistry.js';
41
41
  import { createCaddyProxy, isDockerEnv, } from '../caddy/caddyProxy.js';
42
42
  import { PortLock } from '../caddy/portLock.js';
43
- import { createNgrokTransport, } from '../tunnel/ngrokTransport.js';
44
- import { createDebuggTransport } from '../tunnel/debuggTransport.js';
43
+ import { createDebuggTransport } from './debuggTransport.js';
45
44
  import { extractTunnelIdFromHost, isTunnelHost, } from '../../utils/tunnelDomains.js';
46
45
  /**
47
46
  * The hostname suffix a tunnel gets when the provision response does not name
48
- * one — i.e. the ngrok path, which is every tunnel until the backend starts
49
- * selecting the debugg transport.
47
+ * one. The backend always names one, so this is a floor rather than a path
48
+ * anything is expected to take — but a tunnel with no hostname at all is
49
+ * unusable, and silently building one on a domain we do not own would be worse
50
+ * than building one here.
50
51
  */
51
- const DEFAULT_TUNNEL_DOMAIN = 'ngrok.debugg.ai';
52
+ const DEFAULT_TUNNEL_DOMAIN = 'tunnel.debugg.ai';
52
53
  const logger = new Logger({ module: 'tunnelManager' });
53
54
  // ── Session identity (§2.1) ────────────────────────────────────────────────────
54
55
  /**
@@ -109,50 +110,22 @@ class TunnelManager {
109
110
  */
110
111
  idleTimeoutMs = 55 * 60 * 1000;
111
112
  /**
112
- * Backoff schedule (ms) between ngrok.connect() retry attempts. Bead ixh.
113
+ * Backoff schedule (ms) between transport connect() retry attempts. Bead ixh.
113
114
  * Exposed on the class so tests can override with short delays without
114
115
  * changing the public API or depending on jest fake timers.
115
116
  */
116
117
  connectBackoffMs = [500, 1500];
117
118
  /**
118
- * The transport implementations, keyed by the kind a provision response can
119
- * select. Public and mutable in the same spirit as `caddyFactory`: a test
120
- * drives a fake transport instead of a real agent or websocket.
119
+ * The tunnel transport. Public and mutable in the same spirit as
120
+ * `caddyFactory`: a test drives a fake transport instead of a real websocket
121
+ * to a real relay.
121
122
  *
122
- * A provision response with no `transport` means ngrok, so an old backend
123
- * and a new client keep working together.
123
+ * This was a `Record<kind, TunnelTransport>` while the ngrok and debugg
124
+ * clients ran side by side. With ngrok deleted it is one field again — a map
125
+ * with a single key, keyed by a union with a single member, is bookkeeping
126
+ * for a choice nobody makes any more.
124
127
  */
125
- transports = {
126
- ngrok: createNgrokTransport(),
127
- debugg: createDebuggTransport({ clientVersion: `debugg-ai-mcp/${config.server.version}` }),
128
- };
129
- /**
130
- * Bead pqgj: how the ngrok agent gets started + how we learn its client
131
- * session is live. Overridable so tests can drive a fake agent instead of
132
- * spawning a real ngrok process.
133
- *
134
- * The pre-warm itself moved into ngrokTransport (it is ngrok-only, and the
135
- * debugg path must never touch the `ngrok` package), but the seam stays here
136
- * because it is public API the existing suites drive.
137
- */
138
- get agentSessionStarter() {
139
- return this.transports.ngrok.agentSessionStarter;
140
- }
141
- set agentSessionStarter(starter) {
142
- this.transports.ngrok.agentSessionStarter = starter;
143
- }
144
- /**
145
- * Cap on waiting for "client session established". Measured live at ~293ms;
146
- * this is a generous ceiling, not an expected wait. On expiry we tunnel
147
- * anyway and let the retry ladder handle it — a slow session must not become
148
- * a hang.
149
- */
150
- get agentSessionTimeoutMs() {
151
- return this.transports.ngrok.agentSessionTimeoutMs;
152
- }
153
- set agentSessionTimeoutMs(ms) {
154
- this.transports.ngrok.agentSessionTimeoutMs = ms;
155
- }
128
+ transport = createDebuggTransport({ clientVersion: `debugg-ai-mcp/${config.server.version}` });
156
129
  /**
157
130
  * §2.2/§2.3: one fresh `CaddyProxy` instance PER SESSION KEY, never a
158
131
  * process-wide singleton. Overridable so tests drive a fake proxy instead
@@ -183,7 +156,7 @@ class TunnelManager {
183
156
  *
184
157
  * §2.3's cold-start TOCTOU fix: the read (`sessionTunnels.get`) and the
185
158
  * eventual write (`sessionTunnels.set`) are separated by several `await`
186
- * points (spawning Caddy, connecting ngrok). Two near-simultaneous first
159
+ * points (spawning Caddy, connecting the tunnel). Two near-simultaneous first
187
160
  * calls for the same fresh session key — exactly what an orchestrating
188
161
  * agent produces (an initial navigate fired alongside an initial probe) —
189
162
  * would otherwise both observe a miss and each mint their own tunnel,
@@ -231,8 +204,8 @@ class TunnelManager {
231
204
  // ── Public API — the run_test_suite exception (§2.3) ───────────────────────
232
205
  /**
233
206
  * Used ONLY by runTestSuiteHandler.ts. Bypasses Caddy/PortLock entirely —
234
- * dials ngrok straight at the app, exactly like today's per-port
235
- * createTunnel(). Governed by the same idleTimeoutMs auto-shutoff as any
207
+ * the tunnel dials straight at the app, exactly like the pre-cutover
208
+ * per-port createTunnel(). Governed by the same idleTimeoutMs auto-shutoff as any
236
209
  * other tunnel. This is a deliberate, scoped exception to "one tunnel per
237
210
  * session" (§2.3) — not a smuggled-in legacy fallback — forced by
238
211
  * run_test_suite's async execution model: it is fire-and-forget (no poll
@@ -250,11 +223,10 @@ class TunnelManager {
250
223
  if (!port) {
251
224
  throw new Error(`acquireDedicatedTunnel: could not extract port from localhost URL: ${url}`);
252
225
  }
253
- const transportKind = selection?.transport ?? 'ngrok';
254
- // The ngrok path mints its own id, as it always has — an ngrok key is not
255
- // bound to a hostname. A debugg token IS bound to its Tunnel record, so a
256
- // client-minted uuid would be rejected with 401 and the id the backend
257
- // issued must be used instead.
226
+ // The token IS bound to its Tunnel record, so a client-minted uuid is
227
+ // rejected with 401 and the id the backend issued must be used. The uuidv4()
228
+ // is a floor for callers that pass no provision at all (tests); it cannot
229
+ // authenticate against a real relay.
258
230
  const tunnelId = selection?.tunnelId ?? uuidv4();
259
231
  const domain = selection?.tunnelDomain ?? DEFAULT_TUNNEL_DOMAIN;
260
232
  const tunnelDomain = `${tunnelId}.${domain}`;
@@ -263,14 +235,14 @@ class TunnelManager {
263
235
  // NOTE: this intentionally does NOT reuse caddyProxy.ts's
264
236
  // resolveDialAddress() — that function builds Caddy's JSON `dial` field,
265
237
  // which is always a bare `host:port` (Caddy conveys TLS-ness via its
266
- // separate `transport` field, never a URL scheme in `dial`). ngrok's
267
- // own `connect({ addr })` option is a different consumer with a
268
- // different format: it DOES need a `https://` scheme prefix for an
269
- // HTTPS local target (see the original tunnelManager.ts:696-701, which
270
- // this path preserves byte-for-byte since it dials the app directly,
271
- // exactly like the pre-cutover per-port createTunnel()). Reusing
272
- // resolveDialAddress() here would silently drop that scheme and break
273
- // HTTPS dedicated tunnels.
238
+ // separate `transport` field, never a URL scheme in `dial`). A transport's
239
+ // `connect(localAddr)` is a different consumer with a different format: it
240
+ // DOES need a `https://` scheme prefix for an HTTPS local target (see
241
+ // debuggTransport's parseLocalAddr, and the original
242
+ // tunnelManager.ts:696-701 this path preserves byte-for-byte since it
243
+ // dials the app directly, exactly like the pre-cutover per-port
244
+ // createTunnel()). Reusing resolveDialAddress() here would silently drop
245
+ // that scheme and break HTTPS dedicated tunnels.
274
246
  const dockerHost = 'host.docker.internal';
275
247
  let localAddr;
276
248
  if (isHttpsLocal) {
@@ -287,7 +259,6 @@ class TunnelManager {
287
259
  trace.emit('acquireDedicatedTunnel.start', { port, tunnelId, hasFaultMode: !!faultMode });
288
260
  try {
289
261
  const tunnelUrl = await this.connectWithRetry(localAddr, tunnelDomain, authToken, trace, faults, {
290
- transport: transportKind,
291
262
  tunnelId,
292
263
  relayUrl: selection?.relayUrl,
293
264
  onDead: (reason) => {
@@ -297,7 +268,7 @@ class TunnelManager {
297
268
  });
298
269
  const now = Date.now();
299
270
  const info = {
300
- tunnelId, transport: transportKind, tunnelUrl, createdAt: now, lastAccessedAt: now, keyId, revokeKey,
271
+ tunnelId, tunnelUrl, createdAt: now, lastAccessedAt: now, keyId, revokeKey,
301
272
  };
302
273
  this.directTunnels.set(tunnelId, info);
303
274
  this.writeRegistryEntry(tunnelId, `dedicated:${tunnelId}`, tunnelUrl, -1);
@@ -305,7 +276,7 @@ class TunnelManager {
305
276
  trace.emit('acquireDedicatedTunnel.success', { tunnelId, tunnelUrl });
306
277
  logger.info(`Dedicated tunnel created: ${tunnelUrl} -> localhost:${port}`);
307
278
  Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, {
308
- tunnelId, how: 'created-dedicated', transport: transportKind,
279
+ tunnelId, how: 'created-dedicated', transport: this.transport.kind,
309
280
  });
310
281
  return { url: generateTunnelUrl(url, tunnelId, domain), tunnelId };
311
282
  }
@@ -321,7 +292,7 @@ class TunnelManager {
321
292
  }
322
293
  // ── Public API — lifecycle / teardown ──────────────────────────────────────
323
294
  /**
324
- * Evict a tunnel that a health probe PROVED dead (e.g. ERR_NGROK_3200) —
295
+ * Evict a tunnel that a health probe PROVED dead (e.g. DEBUGG_TUNNEL_UNKNOWN) —
325
296
  * simplifies to a plain delegate now that every tunnel is created (never
326
297
  * borrowed) by this process: there is no shared-registry adoption record
327
298
  * to also evict (bead k34o's second half retired along with borrowing,
@@ -333,7 +304,7 @@ class TunnelManager {
333
304
  /**
334
305
  * `stopTunnel()`'s ordering is a load-bearing contract, not an
335
306
  * implementation detail (§2.3): map removal is UNCONDITIONAL and happens
336
- * before any cleanup I/O, so a downstream cleanup failure (ngrok
307
+ * before any cleanup I/O, so a downstream cleanup failure (tunnel
337
308
  * disconnect, `caddy.stop()`, key revoke) can never leave a
338
309
  * live-looking-but-actually-dead `TunnelInfo` behind for the next call to
339
310
  * find — the exact failure mode the `onPortChanged`-triggered eviction
@@ -412,10 +383,8 @@ class TunnelManager {
412
383
  }
413
384
  // ── Session tunnel creation ─────────────────────────────────────────────────
414
385
  async createSessionTunnel(sessionKey, authToken, specificTunnelId, keyId, revokeKey, selection) {
415
- const transportKind = selection?.transport ?? 'ngrok';
416
386
  const tunnelId = specificTunnelId ?? uuidv4();
417
- // The hostname comes from the provision response for a debugg tunnel, and
418
- // is ngrok's for everything else — minted ONCE per session either way.
387
+ // The hostname comes from the provision response, minted ONCE per session.
419
388
  const tunnelDomain = `${tunnelId}.${selection?.tunnelDomain ?? DEFAULT_TUNNEL_DOMAIN}`;
420
389
  const caddy = this.caddyFactory(); // NEW instance per session key — never a process-wide singleton
421
390
  const { localOrigin, adminPort } = await caddy.ensureStarted();
@@ -427,12 +396,11 @@ class TunnelManager {
427
396
  const trace = new TunnelTrace();
428
397
  trace.emit('createSessionTunnel.start', { tunnelId, sessionKey, hasFaultMode: !!faultMode });
429
398
  try {
430
- // ngrok's own dial target is now always plain loopback HTTP to Caddy —
399
+ // The tunnel's own dial target is always plain loopback HTTP to Caddy —
431
400
  // no HTTPS/Docker complexity on this leg at all (Caddy runs in the same
432
401
  // host/container as the MCP server). That matrix moved entirely into
433
402
  // caddyProxy.setUpstream(), invoked per-dispatch, not per-tunnel-creation.
434
403
  const tunnelUrl = await this.connectWithRetry(localOrigin, tunnelDomain, authToken, trace, faults, {
435
- transport: transportKind,
436
404
  tunnelId,
437
405
  relayUrl: selection?.relayUrl,
438
406
  // A transport that reports the tunnel permanently gone (revoked, or
@@ -447,19 +415,19 @@ class TunnelManager {
447
415
  const portLock = new PortLock((t) => caddy.setUpstream(t));
448
416
  caddy.onPortChanged(() => {
449
417
  // A crash-triggered respawn landed on a DIFFERENT local proxy port
450
- // (sticky-port reclaim failed). The existing ngrok tunnel is now
418
+ // (sticky-port reclaim failed). The existing tunnel is now
451
419
  // dialing a dead port — nothing Caddy-internal can fix this; the
452
420
  // whole session tunnel must be torn down and recreated on the next
453
421
  // call. stopTunnel() never throws (its unconditional-removal
454
422
  // contract, above), so this needs no defensive .catch() of its own.
455
423
  logger.error(`Caddy proxy port changed under session ${sessionKey} — evicting tunnel ${tunnelId}`);
456
424
  Telemetry.capture(TelemetryEvents.TUNNEL_EVICTED_PORT_CHANGED, {
457
- tunnelId, sessionKey, transport: transportKind,
425
+ tunnelId, sessionKey, transport: this.transport.kind,
458
426
  });
459
427
  void this.stopTunnel(tunnelId);
460
428
  });
461
429
  const info = {
462
- tunnelId, sessionKey, transport: transportKind, tunnelUrl, createdAt: now, lastAccessedAt: now,
430
+ tunnelId, sessionKey, tunnelUrl, createdAt: now, lastAccessedAt: now,
463
431
  keyId, revokeKey, caddy, portLock,
464
432
  };
465
433
  this.activeTunnels.set(tunnelId, info);
@@ -468,7 +436,9 @@ class TunnelManager {
468
436
  this.armIdleTimer(info);
469
437
  trace.emit('createSessionTunnel.success', { tunnelId, tunnelUrl });
470
438
  logger.info(`Session tunnel created: ${tunnelUrl} (session ${sessionKey})`);
471
- Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { tunnelId, how: 'created', transport: transportKind });
439
+ Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, {
440
+ tunnelId, how: 'created', transport: this.transport.kind,
441
+ });
472
442
  return info;
473
443
  }
474
444
  catch (error) {
@@ -489,43 +459,36 @@ class TunnelManager {
489
459
  }
490
460
  // ── Shared connect-retry ladder (KEPT byte-for-byte — bead ixh/pqgj/42g/fhg) ─
491
461
  /**
492
- * Bead ixh: 3-attempt retry for ngrok.connect transient failures.
462
+ * Bead ixh: 3-attempt retry for transient connect failures.
493
463
  * - Attempt 1: fresh connect
494
- * - Attempt 2: after 500ms backoff, reset the ngrok agent module and retry
495
- * (existing "agent died" recovery path)
496
- * - Attempt 3: after 1500ms backoff, retry with the already-reset agent
464
+ * - Attempt 2: after 500ms backoff, retry
465
+ * - Attempt 3: after 1500ms backoff, retry
497
466
  * Auth-token errors short-circuit at any attempt — no point looping.
498
467
  *
468
+ * The ladder used to reset the ngrok agent module between attempts 1 and 2,
469
+ * because an out-of-process agent that had died was the failure this existed
470
+ * to recover from. The debugg transport has no out-of-process agent and owns
471
+ * its own socket lifecycle, so the ladder is now purely backoff-and-retry.
472
+ *
499
473
  * Parameterized by `localAddr` rather than computing it internally: the
500
474
  * session-tunnel path (createSessionTunnel) always dials Caddy's fixed
501
475
  * local origin; the dedicated-tunnel path (acquireDedicatedTunnel) dials
502
476
  * the app directly via the isHttpsLocal/inDocker matrix. Both need the
503
- * IDENTICAL retry/backoff/fault-injection/agent-prewarm behavior, so it
504
- * lives here once.
477
+ * IDENTICAL retry/backoff/fault-injection behavior, so it lives here once.
505
478
  */
506
479
  async connectWithRetry(localAddr, tunnelDomain, authToken, trace, faults, connectSpec) {
507
480
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
508
481
  const BACKOFF_MS = this.connectBackoffMs; // bead ixh: test-overridable
509
482
  const MAX_ATTEMPTS = BACKOFF_MS.length + 1; // N sleeps between N+1 attempts
510
- const transport = this.transports[connectSpec.transport];
511
- if (!transport) {
512
- throw new Error(`No tunnel transport registered for "${connectSpec.transport}"`);
513
- }
514
- // Telemetry stage: the ngrok path keeps its existing value so dashboards
515
- // built on it stay continuous.
516
- const connectStage = transport.kind === 'ngrok' ? 'ngrok_connect' : 'debugg_connect';
483
+ const transport = this.transport;
484
+ // Telemetry stage: kept as an explicit constant so dashboards built on the
485
+ // retry event's `stage` dimension stay continuous.
486
+ const connectStage = 'debugg_connect';
517
487
  const connectOpts = {
518
488
  tunnelId: connectSpec.tunnelId,
519
489
  relayUrl: connectSpec.relayUrl,
520
490
  onDead: connectSpec.onDead,
521
491
  };
522
- // Bead pqgj: pre-warm the agent session so attempt 1 doesn't race the ngrok
523
- // agent's ~293ms not-ready window (which poisons the tunnel name via
524
- // ngrok's own name-reusing internal retry and surfaces as
525
- // "invalid tunnel configuration"). Transport-specific and a no-op for
526
- // debugg, which has no agent to warm.
527
- await transport.prepare?.(authToken);
528
- trace.emit('transport.prepared', { transport: transport.kind });
529
492
  let lastError;
530
493
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
531
494
  trace.emit('connect.attempt.start', { attempt });
@@ -569,7 +532,8 @@ class TunnelManager {
569
532
  // build is too old). Retrying those sends the identical request.
570
533
  const declaredTerminal = err?.retryable === false;
571
534
  // Auth-class errors are non-retryable for the same reason. Kept as a
572
- // message test as well, because ngrok only ever reports them that way.
535
+ // message test as well as a flag, so an auth failure that reaches us
536
+ // as text rather than as `retryable: false` still stops the ladder.
573
537
  if (declaredTerminal || /authtoken|unauthorized|\b401\b|\b403\b/i.test(msg)) {
574
538
  const reason = declaredTerminal ? 'transport-terminal' : 'auth-error';
575
539
  trace.emit('connect.giving-up', { reason });
@@ -593,18 +557,7 @@ class TunnelManager {
593
557
  trace.emit('connect.giving-up', { reason: 'max-attempts' });
594
558
  throw err;
595
559
  }
596
- // Between attempt 1→2, let the transport reset itself — for ngrok that
597
- // is the agent reset that covers the "agent died" failure mode, which
598
- // used to be the only retried case. Between 2→3, just wait: the reset
599
- // already happened. A transport with nothing to reset does nothing.
600
- if (attempt === 1) {
601
- logger.warn(`${transport.kind} connect failed (attempt 1/${MAX_ATTEMPTS}), resetting transport: ${msg}`);
602
- trace.emit('agent.reset');
603
- await transport.resetAfterFailedAttempt?.();
604
- }
605
- else {
606
- logger.warn(`${transport.kind} connect failed (attempt ${attempt}/${MAX_ATTEMPTS}), will retry: ${msg}`);
607
- }
560
+ logger.warn(`${transport.kind} connect failed (attempt ${attempt}/${MAX_ATTEMPTS}), will retry: ${msg}`);
608
561
  const backoffMs = BACKOFF_MS[attempt - 1] ?? BACKOFF_MS[BACKOFF_MS.length - 1];
609
562
  trace.emit('connect.backoff', { attempt, backoffMs });
610
563
  await sleep(backoffMs);
@@ -626,7 +579,7 @@ class TunnelManager {
626
579
  clearTimeout(info.autoShutoffTimer);
627
580
  this.removeRegistryEntry(info.tunnelId);
628
581
  const results = await Promise.allSettled([
629
- this.transports[info.transport].disconnect(info.tunnelUrl),
582
+ this.transport.disconnect(info.tunnelUrl),
630
583
  info.caddy.stop(),
631
584
  info.revokeKey ? info.revokeKey() : Promise.resolve(),
632
585
  ]);
@@ -638,10 +591,9 @@ class TunnelManager {
638
591
  Telemetry.capture(TelemetryEvents.TUNNEL_TEARDOWN_PARTIAL_FAILURE, { tunnelId: info.tunnelId, step: i });
639
592
  }
640
593
  });
641
- this.notifyIfNoTunnelsRemain(info.transport);
642
594
  logger.info(`Cleaned up session tunnel: ${info.tunnelUrl}`);
643
595
  Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, {
644
- tunnelId: info.tunnelId, reason: 'stopped', transport: info.transport,
596
+ tunnelId: info.tunnelId, reason: 'stopped', transport: this.transport.kind,
645
597
  });
646
598
  }
647
599
  async stopDirectTunnel(info) {
@@ -651,7 +603,7 @@ class TunnelManager {
651
603
  clearTimeout(info.autoShutoffTimer);
652
604
  this.removeRegistryEntry(info.tunnelId);
653
605
  const results = await Promise.allSettled([
654
- this.transports[info.transport].disconnect(info.tunnelUrl),
606
+ this.transport.disconnect(info.tunnelUrl),
655
607
  info.revokeKey ? info.revokeKey() : Promise.resolve(),
656
608
  ]);
657
609
  results.forEach((r, i) => {
@@ -660,29 +612,11 @@ class TunnelManager {
660
612
  Telemetry.capture(TelemetryEvents.TUNNEL_TEARDOWN_PARTIAL_FAILURE, { tunnelId: info.tunnelId, step: i });
661
613
  }
662
614
  });
663
- this.notifyIfNoTunnelsRemain(info.transport);
664
615
  logger.info(`Cleaned up dedicated tunnel: ${info.tunnelUrl}`);
665
616
  Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, {
666
- tunnelId: info.tunnelId, reason: 'stopped', transport: info.transport,
617
+ tunnelId: info.tunnelId, reason: 'stopped', transport: this.transport.kind,
667
618
  });
668
619
  }
669
- /**
670
- * Tell a transport when the last tunnel OF ITS KIND is gone. ngrok uses it to
671
- * reset its module and init state, because the agent process may have exited
672
- * with it; debugg has nothing process-wide to reset.
673
- *
674
- * Counted per transport, not globally: while both are in play, a debugg
675
- * tunnel shutting down must not make the ngrok transport throw away a live
676
- * agent that another tunnel is still using.
677
- */
678
- notifyIfNoTunnelsRemain(transport) {
679
- const remaining = [...this.activeTunnels.values(), ...this.directTunnels.values()]
680
- .some((t) => t.transport === transport);
681
- if (remaining)
682
- return;
683
- logger.info(`No ${transport} tunnels remain — notifying its transport`);
684
- this.transports[transport].onAllTunnelsStopped?.();
685
- }
686
620
  // ── Idle timer (KEPT — minus the retired cross-process extension branch) ───
687
621
  /**
688
622
  * Bead y7x6/lc62's cross-process "another process touched the registry
@@ -6,10 +6,10 @@
6
6
  * discovered and BORROWED an existing tunnel instead of provisioning a
7
7
  * duplicate for the same port. That borrowing mechanism (and everything that
8
8
  * existed only to make it safe — freshness TTLs, PID-reuse defenses,
9
- * adopt/reconcile against the local ngrok agent) is retired outright by
9
+ * adopt/reconcile against a local tunnel agent) is retired outright by
10
10
  * docs/local-tunnel-multiplexer-architecture-2026-07-31.md §4: "No sharing,
11
11
  * ever, at any granularity. Each session key gets its own Caddy instance and
12
- * its own ngrok tunnel."
12
+ * its own tunnel."
13
13
  *
14
14
  * What remains is a diagnostic view: `RegistryEntry` rows, keyed by
15
15
  * `tunnelId` (not port — a port no longer identifies anything unique once
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Tunnels Service
3
- * Provisions short-lived ngrok keys for MCP-managed tunnel setup.
3
+ * Provisions short-lived debugg tunnel tokens for MCP-managed tunnel setup.
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
7
  import { isValidTunnelDomain, registerTunnelDomain, } from '../utils/tunnelDomains.js';
8
8
  /**
9
- * Typed error thrown by provision() when the backend/ngrok path fails.
9
+ * Typed error thrown by provision() when the backend tunnel path fails.
10
10
  * Carries diagnostic fields a retry wrapper (bead 7nx) can use to decide
11
11
  * whether to retry, and that handler error messages can surface so users
12
12
  * have something actionable to file bug reports against.
@@ -81,8 +81,16 @@ export function classifyProvisionError(err) {
81
81
  }
82
82
  const DEFAULT_BACKOFF_MS = [500, 1500, 3000];
83
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']);
84
+ /**
85
+ * The transports this client can actually drive.
86
+ *
87
+ * KEEP SENDING THIS, even though it is now a list of one. The backend's
88
+ * negotiation treats a request that offers NOTHING as a pre-negotiation client
89
+ * and hands it ngrok (design §2), which this client can no longer speak. An
90
+ * empty or absent `transports` is therefore not a simplification — it is how
91
+ * you get handed a tunnel you cannot connect to.
92
+ */
93
+ export const SUPPORTED_TRANSPORTS = Object.freeze(['debugg']);
86
94
  /** A relay URL must be wss, except against a loopback backend (local dev, tests). */
87
95
  function isAcceptableRelayUrl(relayUrl) {
88
96
  let url;
@@ -114,61 +122,71 @@ export const createTunnelsService = (tx) => {
114
122
  retryable: false,
115
123
  });
116
124
  }
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)) {
125
+ // Every failure below is non-retryable: retrying cannot turn a response
126
+ // this client cannot use into one it can, and TunnelManager must never be
127
+ // handed a token with nowhere to send it.
128
+ //
129
+ // The backend still negotiates, so it can still answer "ngrok" — to a
130
+ // client that no longer has an ngrok transport to answer with. That is a
131
+ // version mismatch, and it gets its own message rather than a generic
132
+ // "unsupported transport", because it is the only thing the user will see
133
+ // and the fix is a specific one.
134
+ if (response.transport === 'ngrok') {
135
+ throw new TunnelProvisionError({
136
+ message: 'Tunnel provisioning selected the ngrok transport, which this version of ' +
137
+ '@debugg-ai/debugg-ai-mcp no longer implements (it offered: ' +
138
+ `${SUPPORTED_TRANSPORTS.join(', ')}). Either the backend has not been moved off ngrok yet, ` +
139
+ 'or this account is still pinned to it. Pin @debugg-ai/debugg-ai-mcp@4.4.1 until the ' +
140
+ 'backend serves debugg tunnels.',
141
+ retryable: false,
142
+ });
143
+ }
144
+ if (response.transport !== undefined && !SUPPORTED_TRANSPORTS.includes(response.transport)) {
121
145
  throw new TunnelProvisionError({
122
146
  message: `Tunnel provisioning selected transport "${response.transport}", which this client cannot speak ` +
123
147
  `(it offered ${SUPPORTED_TRANSPORTS.join(', ')}). Update @debugg-ai/debugg-ai-mcp.`,
124
148
  retryable: false,
125
149
  });
126
150
  }
127
- const result = {
151
+ // A response with no `transport` at all predates negotiation entirely. It
152
+ // is only usable if it is self-describing — i.e. it carries the debugg
153
+ // fields anyway. Otherwise it is an ngrok provision in all but name.
154
+ if (!response.relayUrl || !response.tunnelDomain) {
155
+ throw new TunnelProvisionError({
156
+ message: 'Tunnel provisioning returned no relayUrl/tunnelDomain, so there is no debugg tunnel to ' +
157
+ 'connect to. This backend predates the debugg tunnel server; pin ' +
158
+ '@debugg-ai/debugg-ai-mcp@4.4.1 until it is upgraded.',
159
+ retryable: false,
160
+ });
161
+ }
162
+ if (!isAcceptableRelayUrl(response.relayUrl)) {
163
+ throw new TunnelProvisionError({
164
+ message: `Tunnel provisioning returned a relayUrl that is not wss (${response.relayUrl}); ` +
165
+ 'refusing to send the tunnel key over it',
166
+ retryable: false,
167
+ });
168
+ }
169
+ if (!isValidTunnelDomain(response.tunnelDomain)) {
170
+ throw new TunnelProvisionError({
171
+ message: `Tunnel provisioning returned an unusable tunnelDomain (${response.tunnelDomain})`,
172
+ retryable: false,
173
+ });
174
+ }
175
+ // Registering it here is what stops a tunnel URL leaking: everything that
176
+ // recognises or rewrites a tunnel hostname reads this same registry, so a
177
+ // domain the backend moves to is covered the moment it is handed to us.
178
+ registerTunnelDomain(response.tunnelDomain);
179
+ return {
128
180
  tunnelId: response.tunnelId,
129
181
  tunnelKey: response.tunnelKey,
130
182
  keyId: response.keyId,
131
183
  expiresAt: response.expiresAt,
132
- transport,
184
+ relayUrl: response.relayUrl,
185
+ tunnelDomain: response.tunnelDomain,
133
186
  };
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
187
  }
166
188
  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 });
189
+ await tx.post(`api/v1/tunnels/${provisionInfo.tunnelId}/revoke/`, {});
172
190
  }
173
191
  async function provisionWithRetry(opts = {}) {
174
192
  const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;