@debugg-ai/debugg-ai-mcp 4.2.2 → 4.4.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 (34) hide show
  1. package/README.md +10 -4
  2. package/dist/handlers/createEnvironmentHandler.js +10 -0
  3. package/dist/handlers/probePageHandler.js +14 -7
  4. package/dist/handlers/runTestSuiteHandler.js +6 -5
  5. package/dist/handlers/testPageChangesHandler.js +20 -10
  6. package/dist/handlers/triggerCrawlHandler.js +7 -6
  7. package/dist/handlers/updateEnvironmentHandler.js +14 -2
  8. package/dist/services/index.js +21 -0
  9. package/dist/services/ngrok/tunnelManager.js +157 -161
  10. package/dist/services/tunnel/debuggTransport.js +439 -0
  11. package/dist/services/tunnel/ngrokTransport.js +165 -0
  12. package/dist/services/tunnel/probeRegistry.js +52 -0
  13. package/dist/services/tunnel/protocol/codec.js +327 -0
  14. package/dist/services/tunnel/protocol/conformance.js +171 -0
  15. package/dist/services/tunnel/protocol/constants.js +178 -0
  16. package/dist/services/tunnel/protocol/errors.js +51 -0
  17. package/dist/services/tunnel/protocol/handshake.js +134 -0
  18. package/dist/services/tunnel/protocol/index.js +17 -0
  19. package/dist/services/tunnel/protocol/session.js +668 -0
  20. package/dist/services/tunnel/protocol/stream.js +109 -0
  21. package/dist/services/tunnel/protocol/transport.js +106 -0
  22. package/dist/services/tunnel/transport.js +15 -0
  23. package/dist/services/tunnels.js +71 -3
  24. package/dist/services/verdictAdapter.js +25 -1
  25. package/dist/tools/environment.js +5 -2
  26. package/dist/tools/testPageChanges.js +1 -1
  27. package/dist/types/index.js +29 -2
  28. package/dist/utils/authorizedCredentialHosts.js +38 -0
  29. package/dist/utils/localReachability.js +160 -3
  30. package/dist/utils/tunnelContext.js +6 -3
  31. package/dist/utils/tunnelDisposition.js +16 -1
  32. package/dist/utils/tunnelDomains.js +113 -0
  33. package/dist/utils/urlParser.js +8 -2
  34. package/package.json +3 -1
@@ -30,6 +30,7 @@
30
30
  * bypasses Caddy/PortLock entirely — see §2.3.
31
31
  */
32
32
  import { Logger } from '../../utils/logger.js';
33
+ import { config } from '../../config/index.js';
33
34
  import { Telemetry, TelemetryEvents } from '../../utils/telemetry.js';
34
35
  import { extractLocalhostPort, generateTunnelUrl } from '../../utils/urlParser.js';
35
36
  import { currentApiKey } from '../../utils/requestContext.js';
@@ -37,28 +38,17 @@ import { createHash } from 'node:crypto';
37
38
  import { v4 as uuidv4 } from 'uuid';
38
39
  import { FaultInjector, TunnelTrace, getFaultModeFromEnv } from './tunnelFaultInjection.js';
39
40
  import { getDefaultRegistry, } from './tunnelRegistry.js';
40
- import { startAgentSession } from './ngrokAgentSession.js';
41
41
  import { createCaddyProxy, isDockerEnv, } from '../caddy/caddyProxy.js';
42
42
  import { PortLock } from '../caddy/portLock.js';
43
- let ngrokModule = null;
44
- async function getNgrok() {
45
- if (!ngrokModule) {
46
- try {
47
- ngrokModule = await import('ngrok');
48
- }
49
- catch (error) {
50
- throw new Error(`Failed to load ngrok module: ${error}`);
51
- }
52
- }
53
- return ngrokModule;
54
- }
43
+ import { createNgrokTransport, } from '../tunnel/ngrokTransport.js';
44
+ import { createDebuggTransport } from '../tunnel/debuggTransport.js';
45
+ import { extractTunnelIdFromHost, isTunnelHost, } from '../../utils/tunnelDomains.js';
55
46
  /**
56
- * Reset the cached ngrok module so the next connect() bootstraps a fresh agent.
57
- * Called when the last tunnel is disconnected and the agent process may have died.
47
+ * 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.
58
50
  */
59
- function resetNgrokModule() {
60
- ngrokModule = null;
61
- }
51
+ const DEFAULT_TUNNEL_DOMAIN = 'ngrok.debugg.ai';
62
52
  const logger = new Logger({ module: 'tunnelManager' });
63
53
  // ── Session identity (§2.1) ────────────────────────────────────────────────────
64
54
  /**
@@ -112,7 +102,6 @@ class TunnelManager {
112
102
  * session key join one creation instead of each minting their own (§2.3's
113
103
  * cold-start TOCTOU fix — see ensureSessionTunnel()). */
114
104
  pendingSessionTunnels = new Map();
115
- initialized = false;
116
105
  /**
117
106
  * Idle window before a tunnel auto-shuts-off. Public so timer tests can run
118
107
  * in milliseconds instead of 55 minutes, matching the `connectBackoffMs` /
@@ -125,29 +114,51 @@ class TunnelManager {
125
114
  * changing the public API or depending on jest fake timers.
126
115
  */
127
116
  connectBackoffMs = [500, 1500];
117
+ /**
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.
121
+ *
122
+ * A provision response with no `transport` means ngrok, so an old backend
123
+ * and a new client keep working together.
124
+ */
125
+ transports = {
126
+ ngrok: createNgrokTransport(),
127
+ debugg: createDebuggTransport({ clientVersion: `debugg-ai-mcp/${config.server.version}` }),
128
+ };
128
129
  /**
129
130
  * Bead pqgj: how the ngrok agent gets started + how we learn its client
130
131
  * session is live. Overridable so tests can drive a fake agent instead of
131
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.
132
137
  */
133
- agentSessionStarter = startAgentSession;
138
+ get agentSessionStarter() {
139
+ return this.transports.ngrok.agentSessionStarter;
140
+ }
141
+ set agentSessionStarter(starter) {
142
+ this.transports.ngrok.agentSessionStarter = starter;
143
+ }
134
144
  /**
135
145
  * Cap on waiting for "client session established". Measured live at ~293ms;
136
146
  * this is a generous ceiling, not an expected wait. On expiry we tunnel
137
147
  * anyway and let the retry ladder handle it — a slow session must not become
138
148
  * a hang.
139
149
  */
140
- agentSessionTimeoutMs = 5000;
150
+ get agentSessionTimeoutMs() {
151
+ return this.transports.ngrok.agentSessionTimeoutMs;
152
+ }
153
+ set agentSessionTimeoutMs(ms) {
154
+ this.transports.ngrok.agentSessionTimeoutMs = ms;
155
+ }
141
156
  /**
142
157
  * §2.2/§2.3: one fresh `CaddyProxy` instance PER SESSION KEY, never a
143
158
  * process-wide singleton. Overridable so tests drive a fake proxy instead
144
159
  * of spawning a real `caddy` process.
145
160
  */
146
161
  caddyFactory = createCaddyProxy;
147
- /** Whether the ngrok agent's client session is established (bead pqgj). */
148
- agentSessionReady = false;
149
- /** In-flight session bootstrap, so concurrent tunnels wait on one spawn. */
150
- agentSessionPromise = null;
151
162
  constructor(reg = getDefaultRegistry()) {
152
163
  this.reg = reg;
153
164
  // Bead `mdp`: sweep dead-owner entries on startup so the (now purely
@@ -182,7 +193,7 @@ class TunnelManager {
182
193
  * so whichever call runs its synchronous prefix first wins the map slot,
183
194
  * and the other necessarily observes it on its own synchronous prefix.
184
195
  */
185
- async ensureSessionTunnel(sessionKey, authToken, specificTunnelId, keyId, revokeKey) {
196
+ async ensureSessionTunnel(sessionKey, authToken, specificTunnelId, keyId, revokeKey, selection) {
186
197
  // 1. Fast path: a fully-created tunnel already exists for this session key.
187
198
  const existingId = this.sessionTunnels.get(sessionKey);
188
199
  if (existingId) {
@@ -198,7 +209,7 @@ class TunnelManager {
198
209
  if (inFlight)
199
210
  return inFlight;
200
211
  // 3. First caller for this session key: claim the slot BEFORE any await.
201
- const creation = this.createSessionTunnel(sessionKey, authToken, specificTunnelId, keyId, revokeKey)
212
+ const creation = this.createSessionTunnel(sessionKey, authToken, specificTunnelId, keyId, revokeKey, selection)
202
213
  .finally(() => { this.pendingSessionTunnels.delete(sessionKey); });
203
214
  this.pendingSessionTunnels.set(sessionKey, creation);
204
215
  return creation;
@@ -234,14 +245,19 @@ class TunnelManager {
234
245
  * A session that calls both a Caddy-routed tool AND run_test_suite pays for
235
246
  * 2 tunnels for that session — honest and bounded, flagged in §6.
236
247
  */
237
- async acquireDedicatedTunnel(url, authToken, keyId, revokeKey) {
238
- await this.ensureInitialized();
248
+ async acquireDedicatedTunnel(url, authToken, keyId, revokeKey, selection) {
239
249
  const port = extractLocalhostPort(url);
240
250
  if (!port) {
241
251
  throw new Error(`acquireDedicatedTunnel: could not extract port from localhost URL: ${url}`);
242
252
  }
243
- const tunnelId = uuidv4();
244
- const tunnelDomain = `${tunnelId}.ngrok.debugg.ai`;
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.
258
+ const tunnelId = selection?.tunnelId ?? uuidv4();
259
+ const domain = selection?.tunnelDomain ?? DEFAULT_TUNNEL_DOMAIN;
260
+ const tunnelDomain = `${tunnelId}.${domain}`;
245
261
  const isHttpsLocal = url.startsWith('https:');
246
262
  const inDocker = isDockerEnv();
247
263
  // NOTE: this intentionally does NOT reuse caddyProxy.ts's
@@ -270,18 +286,28 @@ class TunnelManager {
270
286
  const trace = new TunnelTrace();
271
287
  trace.emit('acquireDedicatedTunnel.start', { port, tunnelId, hasFaultMode: !!faultMode });
272
288
  try {
273
- const tunnelUrl = await this.connectWithRetry(localAddr, tunnelDomain, authToken, trace, faults);
289
+ const tunnelUrl = await this.connectWithRetry(localAddr, tunnelDomain, authToken, trace, faults, {
290
+ transport: transportKind,
291
+ tunnelId,
292
+ relayUrl: selection?.relayUrl,
293
+ onDead: (reason) => {
294
+ logger.error(`Dedicated tunnel ${tunnelId} reported dead by its transport (${reason}) — evicting`);
295
+ void this.stopTunnel(tunnelId);
296
+ },
297
+ });
274
298
  const now = Date.now();
275
299
  const info = {
276
- tunnelId, tunnelUrl, createdAt: now, lastAccessedAt: now, keyId, revokeKey,
300
+ tunnelId, transport: transportKind, tunnelUrl, createdAt: now, lastAccessedAt: now, keyId, revokeKey,
277
301
  };
278
302
  this.directTunnels.set(tunnelId, info);
279
303
  this.writeRegistryEntry(tunnelId, `dedicated:${tunnelId}`, tunnelUrl, -1);
280
304
  this.armIdleTimer(info);
281
305
  trace.emit('acquireDedicatedTunnel.success', { tunnelId, tunnelUrl });
282
306
  logger.info(`Dedicated tunnel created: ${tunnelUrl} -> localhost:${port}`);
283
- Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { tunnelId, how: 'created-dedicated' });
284
- return { url: generateTunnelUrl(url, tunnelId), tunnelId };
307
+ Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, {
308
+ tunnelId, how: 'created-dedicated', transport: transportKind,
309
+ });
310
+ return { url: generateTunnelUrl(url, tunnelId, domain), tunnelId };
285
311
  }
286
312
  catch (error) {
287
313
  const msg = error instanceof Error ? error.message : 'Unknown error';
@@ -356,12 +382,12 @@ class TunnelManager {
356
382
  this.touchTunnel(tunnelId);
357
383
  }
358
384
  }
385
+ /** True for a hostname on ANY known tunnel domain (utils/tunnelDomains.ts). */
359
386
  isTunnelUrl(url) {
360
- return url.includes('.ngrok.debugg.ai');
387
+ return isTunnelHost(url);
361
388
  }
362
389
  extractTunnelId(url) {
363
- const match = url.match(/https?:\/\/([^.]+)\.ngrok\.debugg\.ai/);
364
- return match ? match[1] : null;
390
+ return extractTunnelIdFromHost(url);
365
391
  }
366
392
  getTunnelStatus(tunnelId) {
367
393
  const tunnel = this.activeTunnels.get(tunnelId);
@@ -385,10 +411,12 @@ class TunnelManager {
385
411
  return statuses;
386
412
  }
387
413
  // ── Session tunnel creation ─────────────────────────────────────────────────
388
- async createSessionTunnel(sessionKey, authToken, specificTunnelId, keyId, revokeKey) {
389
- await this.ensureInitialized();
414
+ async createSessionTunnel(sessionKey, authToken, specificTunnelId, keyId, revokeKey, selection) {
415
+ const transportKind = selection?.transport ?? 'ngrok';
390
416
  const tunnelId = specificTunnelId ?? uuidv4();
391
- const tunnelDomain = `${tunnelId}.ngrok.debugg.ai`; // UNCHANGED scheme, minted ONCE per session now
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.
419
+ const tunnelDomain = `${tunnelId}.${selection?.tunnelDomain ?? DEFAULT_TUNNEL_DOMAIN}`;
392
420
  const caddy = this.caddyFactory(); // NEW instance per session key — never a process-wide singleton
393
421
  const { localOrigin, adminPort } = await caddy.ensureStarted();
394
422
  logger.info(`Creating session tunnel (domain: ${tunnelDomain}, session: ${sessionKey})`);
@@ -403,7 +431,18 @@ class TunnelManager {
403
431
  // no HTTPS/Docker complexity on this leg at all (Caddy runs in the same
404
432
  // host/container as the MCP server). That matrix moved entirely into
405
433
  // caddyProxy.setUpstream(), invoked per-dispatch, not per-tunnel-creation.
406
- const tunnelUrl = await this.connectWithRetry(localOrigin, tunnelDomain, authToken, trace, faults);
434
+ const tunnelUrl = await this.connectWithRetry(localOrigin, tunnelDomain, authToken, trace, faults, {
435
+ transport: transportKind,
436
+ tunnelId,
437
+ relayUrl: selection?.relayUrl,
438
+ // A transport that reports the tunnel permanently gone (revoked, or
439
+ // auth refused on reconnect) must not leave a corpse behind for the
440
+ // next call to reuse: evict it exactly like a port change does.
441
+ onDead: (reason) => {
442
+ logger.error(`Tunnel ${tunnelId} reported dead by its transport (${reason}) — evicting`);
443
+ void this.stopTunnel(tunnelId);
444
+ },
445
+ });
407
446
  const now = Date.now();
408
447
  const portLock = new PortLock((t) => caddy.setUpstream(t));
409
448
  caddy.onPortChanged(() => {
@@ -414,11 +453,13 @@ class TunnelManager {
414
453
  // call. stopTunnel() never throws (its unconditional-removal
415
454
  // contract, above), so this needs no defensive .catch() of its own.
416
455
  logger.error(`Caddy proxy port changed under session ${sessionKey} — evicting tunnel ${tunnelId}`);
417
- Telemetry.capture(TelemetryEvents.TUNNEL_EVICTED_PORT_CHANGED, { tunnelId, sessionKey });
456
+ Telemetry.capture(TelemetryEvents.TUNNEL_EVICTED_PORT_CHANGED, {
457
+ tunnelId, sessionKey, transport: transportKind,
458
+ });
418
459
  void this.stopTunnel(tunnelId);
419
460
  });
420
461
  const info = {
421
- tunnelId, sessionKey, tunnelUrl, createdAt: now, lastAccessedAt: now,
462
+ tunnelId, sessionKey, transport: transportKind, tunnelUrl, createdAt: now, lastAccessedAt: now,
422
463
  keyId, revokeKey, caddy, portLock,
423
464
  };
424
465
  this.activeTunnels.set(tunnelId, info);
@@ -427,7 +468,7 @@ class TunnelManager {
427
468
  this.armIdleTimer(info);
428
469
  trace.emit('createSessionTunnel.success', { tunnelId, tunnelUrl });
429
470
  logger.info(`Session tunnel created: ${tunnelUrl} (session ${sessionKey})`);
430
- Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { tunnelId, how: 'created' });
471
+ Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { tunnelId, how: 'created', transport: transportKind });
431
472
  return info;
432
473
  }
433
474
  catch (error) {
@@ -462,21 +503,29 @@ class TunnelManager {
462
503
  * IDENTICAL retry/backoff/fault-injection/agent-prewarm behavior, so it
463
504
  * lives here once.
464
505
  */
465
- async connectWithRetry(localAddr, tunnelDomain, authToken, trace, faults) {
506
+ async connectWithRetry(localAddr, tunnelDomain, authToken, trace, faults, connectSpec) {
466
507
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
467
508
  const BACKOFF_MS = this.connectBackoffMs; // bead ixh: test-overridable
468
509
  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';
469
517
  const connectOpts = {
470
- proto: 'http',
471
- addr: localAddr,
472
- hostname: tunnelDomain,
473
- authtoken: authToken,
518
+ tunnelId: connectSpec.tunnelId,
519
+ relayUrl: connectSpec.relayUrl,
520
+ onDead: connectSpec.onDead,
474
521
  };
475
- // Bead pqgj: pre-warm the agent session so attempt 1 doesn't race the
522
+ // Bead pqgj: pre-warm the agent session so attempt 1 doesn't race the ngrok
476
523
  // agent's ~293ms not-ready window (which poisons the tunnel name via
477
524
  // ngrok's own name-reusing internal retry and surfaces as
478
- // "invalid tunnel configuration").
479
- await this.ensureAgentSession(authToken, trace);
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 });
480
529
  let lastError;
481
530
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
482
531
  trace.emit('connect.attempt.start', { attempt });
@@ -487,24 +536,26 @@ class TunnelManager {
487
536
  await sleep(delayMs);
488
537
  }
489
538
  try {
490
- const ngrok = await getNgrok();
491
- // Fault-inject a synthetic failure BEFORE ngrok.connect runs so we
492
- // can simulate connect-layer failures without hitting the real API.
539
+ // Fault-inject a synthetic failure BEFORE the transport runs so we can
540
+ // simulate connect-layer failures without hitting a real API.
493
541
  if (faults.shouldFailConnect()) {
494
542
  trace.emit('connect.fault.inject', { attempt, mode: 'fail-connect-N' });
495
543
  throw new Error(`[fault-inject] synthetic connect failure (attempt ${attempt})`);
496
544
  }
497
- const url = faults.shouldReturnEmptyUrl() ? '' : await ngrok.connect(connectOpts);
545
+ const url = faults.shouldReturnEmptyUrl()
546
+ ? ''
547
+ : await transport.connect(localAddr, tunnelDomain, authToken, connectOpts);
498
548
  if (!url) {
499
549
  trace.emit('connect.attempt.empty-url', { attempt });
500
- throw new Error(`ngrok.connect() returned empty URL (attempt ${attempt})`);
550
+ throw new Error(`${transport.kind} transport returned empty URL (attempt ${attempt})`);
501
551
  }
502
552
  trace.emit('connect.attempt.success', { attempt });
503
553
  if (attempt > 1) {
504
554
  Telemetry.capture(TelemetryEvents.TUNNEL_PROVISION_RETRY, {
505
555
  attempt,
506
556
  outcome: 'success',
507
- stage: 'ngrok_connect',
557
+ stage: connectStage,
558
+ transport: transport.kind,
508
559
  });
509
560
  }
510
561
  return url;
@@ -513,15 +564,21 @@ class TunnelManager {
513
564
  lastError = err;
514
565
  const msg = err instanceof Error ? err.message : String(err);
515
566
  trace.emit('connect.attempt.fail', { attempt, message: msg.slice(0, 200) });
516
- // Auth-class errors are non-retryable — retrying with the same token
517
- // would loop. Let the outer catch classify the message.
518
- if (/authtoken|unauthorized|\b401\b|\b403\b/i.test(msg)) {
519
- trace.emit('connect.giving-up', { reason: 'auth-error' });
567
+ // Non-retryable by construction: a transport says so for the cases
568
+ // where looping cannot help (401 bad/expired key, 400 bad id, 426 this
569
+ // build is too old). Retrying those sends the identical request.
570
+ const declaredTerminal = err?.retryable === false;
571
+ // 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.
573
+ if (declaredTerminal || /authtoken|unauthorized|\b401\b|\b403\b/i.test(msg)) {
574
+ const reason = declaredTerminal ? 'transport-terminal' : 'auth-error';
575
+ trace.emit('connect.giving-up', { reason });
520
576
  Telemetry.capture(TelemetryEvents.TUNNEL_PROVISION_RETRY, {
521
577
  attempt,
522
578
  outcome: 'giving-up',
523
- stage: 'ngrok_connect',
524
- reason: 'auth-error',
579
+ stage: connectStage,
580
+ transport: transport.kind,
581
+ reason,
525
582
  });
526
583
  throw err;
527
584
  }
@@ -529,24 +586,24 @@ class TunnelManager {
529
586
  Telemetry.capture(TelemetryEvents.TUNNEL_PROVISION_RETRY, {
530
587
  attempt,
531
588
  outcome: isLastAttempt ? 'giving-up' : 'will-retry',
532
- stage: 'ngrok_connect',
589
+ stage: connectStage,
590
+ transport: transport.kind,
533
591
  });
534
592
  if (isLastAttempt) {
535
593
  trace.emit('connect.giving-up', { reason: 'max-attempts' });
536
594
  throw err;
537
595
  }
538
- // Between attempt 1→2, do an agent-reset (covers the "agent died"
539
- // failure mode that used to be the only retried case). Between 2→3,
540
- // just wait — the reset already happened.
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.
541
600
  if (attempt === 1) {
542
- logger.warn(`ngrok.connect() failed (attempt 1/${MAX_ATTEMPTS}), resetting agent: ${msg}`);
601
+ logger.warn(`${transport.kind} connect failed (attempt 1/${MAX_ATTEMPTS}), resetting transport: ${msg}`);
543
602
  trace.emit('agent.reset');
544
- resetNgrokModule();
545
- this.initialized = false;
546
- await this.ensureInitialized();
603
+ await transport.resetAfterFailedAttempt?.();
547
604
  }
548
605
  else {
549
- logger.warn(`ngrok.connect() failed (attempt ${attempt}/${MAX_ATTEMPTS}), will retry: ${msg}`);
606
+ logger.warn(`${transport.kind} connect failed (attempt ${attempt}/${MAX_ATTEMPTS}), will retry: ${msg}`);
550
607
  }
551
608
  const backoffMs = BACKOFF_MS[attempt - 1] ?? BACKOFF_MS[BACKOFF_MS.length - 1];
552
609
  trace.emit('connect.backoff', { attempt, backoffMs });
@@ -569,7 +626,7 @@ class TunnelManager {
569
626
  clearTimeout(info.autoShutoffTimer);
570
627
  this.removeRegistryEntry(info.tunnelId);
571
628
  const results = await Promise.allSettled([
572
- this.disconnectNgrok(info.tunnelUrl),
629
+ this.transports[info.transport].disconnect(info.tunnelUrl),
573
630
  info.caddy.stop(),
574
631
  info.revokeKey ? info.revokeKey() : Promise.resolve(),
575
632
  ]);
@@ -581,9 +638,11 @@ class TunnelManager {
581
638
  Telemetry.capture(TelemetryEvents.TUNNEL_TEARDOWN_PARTIAL_FAILURE, { tunnelId: info.tunnelId, step: i });
582
639
  }
583
640
  });
584
- this.maybeResetNgrokModule();
641
+ this.notifyIfNoTunnelsRemain(info.transport);
585
642
  logger.info(`Cleaned up session tunnel: ${info.tunnelUrl}`);
586
- Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, { tunnelId: info.tunnelId, reason: 'stopped' });
643
+ Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, {
644
+ tunnelId: info.tunnelId, reason: 'stopped', transport: info.transport,
645
+ });
587
646
  }
588
647
  async stopDirectTunnel(info) {
589
648
  // Same unconditional-removal contract as stopSessionTunnel, above.
@@ -592,7 +651,7 @@ class TunnelManager {
592
651
  clearTimeout(info.autoShutoffTimer);
593
652
  this.removeRegistryEntry(info.tunnelId);
594
653
  const results = await Promise.allSettled([
595
- this.disconnectNgrok(info.tunnelUrl),
654
+ this.transports[info.transport].disconnect(info.tunnelUrl),
596
655
  info.revokeKey ? info.revokeKey() : Promise.resolve(),
597
656
  ]);
598
657
  results.forEach((r, i) => {
@@ -601,23 +660,28 @@ class TunnelManager {
601
660
  Telemetry.capture(TelemetryEvents.TUNNEL_TEARDOWN_PARTIAL_FAILURE, { tunnelId: info.tunnelId, step: i });
602
661
  }
603
662
  });
604
- this.maybeResetNgrokModule();
663
+ this.notifyIfNoTunnelsRemain(info.transport);
605
664
  logger.info(`Cleaned up dedicated tunnel: ${info.tunnelUrl}`);
606
- Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, { tunnelId: info.tunnelId, reason: 'stopped' });
607
- }
608
- async disconnectNgrok(tunnelUrl) {
609
- const ngrok = await getNgrok();
610
- await ngrok.disconnect(tunnelUrl);
665
+ Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, {
666
+ tunnelId: info.tunnelId, reason: 'stopped', transport: info.transport,
667
+ });
611
668
  }
612
- /** If no tunnels of any kind remain, the ngrok agent process may have
613
- * exited. Reset module + init state so the next connect() bootstraps a
614
- * fresh agent. */
615
- maybeResetNgrokModule() {
616
- if (this.activeTunnels.size === 0 && this.directTunnels.size === 0) {
617
- logger.info('No tunnels remain — resetting ngrok module for fresh init on next request');
618
- resetNgrokModule();
619
- this.initialized = false;
620
- }
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?.();
621
685
  }
622
686
  // ── Idle timer (KEPT — minus the retired cross-process extension branch) ───
623
687
  /**
@@ -681,74 +745,6 @@ class TunnelManager {
681
745
  // best-effort
682
746
  }
683
747
  }
684
- // ── Helpers ─────────────────────────────────────────────────────────────────
685
- /**
686
- * Bead pqgj: make sure the ngrok agent's client session is established before
687
- * we ask it for a tunnel, so attempt 1 lands on a ready agent instead of the
688
- * ~293ms not-ready window that made every single run fail its first connect.
689
- *
690
- * Never throws: if the agent can't be pre-warmed (ngrok internals moved, slow
691
- * session, dead token) we fall through and let connectWithRetry's ladder do
692
- * what it did before this fix. The ladder stays a genuine safety net.
693
- */
694
- async ensureAgentSession(authtoken, trace) {
695
- if (this.agentSessionReady)
696
- return;
697
- if (!this.agentSessionPromise) {
698
- this.agentSessionPromise = (async () => {
699
- let markEstablished;
700
- const established = new Promise((resolve) => { markEstablished = resolve; });
701
- await this.agentSessionStarter({
702
- authtoken,
703
- onStatusChange: (status) => {
704
- if (status === 'connected') {
705
- this.agentSessionReady = true;
706
- markEstablished();
707
- }
708
- else if (status === 'closed') {
709
- this.agentSessionReady = false;
710
- }
711
- },
712
- onTerminated: () => {
713
- // Agent process died — next tunnel must re-warm.
714
- this.agentSessionReady = false;
715
- this.agentSessionPromise = null;
716
- },
717
- });
718
- let capTimer;
719
- const cap = new Promise((resolve) => {
720
- capTimer = setTimeout(resolve, this.agentSessionTimeoutMs);
721
- });
722
- try {
723
- await Promise.race([established, cap]);
724
- }
725
- finally {
726
- if (capTimer)
727
- clearTimeout(capTimer);
728
- }
729
- })().catch((err) => {
730
- // Pre-warm unavailable — not fatal, the ladder covers it.
731
- this.agentSessionPromise = null;
732
- const msg = err instanceof Error ? err.message : String(err);
733
- trace?.emit('agent.session.prewarm-failed', { message: msg.slice(0, 200) });
734
- logger.debug(`ngrok agent pre-warm unavailable, relying on connect retry ladder: ${msg}`);
735
- });
736
- }
737
- await this.agentSessionPromise;
738
- trace?.emit('agent.session.ready', { ready: this.agentSessionReady });
739
- }
740
- async ensureInitialized() {
741
- if (!this.initialized) {
742
- try {
743
- const ngrok = await getNgrok();
744
- ngrok.getApi();
745
- }
746
- catch {
747
- // ignore — let connect surface real errors
748
- }
749
- this.initialized = true;
750
- }
751
- }
752
748
  }
753
749
  const tunnelManager = new TunnelManager();
754
750
  export { tunnelManager };