@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
@@ -11,6 +11,7 @@
11
11
  * and reports back what happened.
12
12
  */
13
13
  import { config } from '../config/index.js';
14
+ import { toUserFacingOutcome, noVerdictReason } from '../services/verdictAdapter.js';
14
15
  import { Logger } from '../utils/logger.js';
15
16
  import { handleExternalServiceError } from '../utils/errors.js';
16
17
  import { DebuggAIServerClient } from '../services/index.js';
@@ -85,14 +86,14 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
85
86
  try {
86
87
  // --- Tunnel: reuse existing or provision a fresh one ---
87
88
  if (ctx.isLocalhost) {
88
- // Bead 1om: pre-flight local port probe BEFORE provision/ngrok/backend.
89
+ // Bead 1om: pre-flight local port probe BEFORE provision/tunnel/backend.
89
90
  const localPort = extractLocalhostPort(ctx.originalUrl);
90
91
  if (typeof localPort === 'number') {
91
92
  const probe = await probeLocalPort(localPort);
92
93
  if (!probe.reachable) {
93
94
  const payload = {
94
95
  error: 'LocalServerUnreachable',
95
- message: `No server listening on 127.0.0.1:${localPort}. Start your dev server on that port before running trigger_crawl. Probe result: ${probe.code} (${probe.detail ?? 'no detail'}).`,
96
+ message: `No server listening on 127.0.0.1:${localPort}.`,
96
97
  detail: { port: localPort, probeCode: probe.code, probeDetail: probe.detail, elapsedMs: probe.elapsedMs },
97
98
  };
98
99
  logger.warn(`Pre-flight port probe failed for ${ctx.originalUrl}: ${probe.code} in ${probe.elapsedMs}ms`);
@@ -119,9 +120,7 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
119
120
  catch (provisionError) {
120
121
  const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
121
122
  const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
122
- throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}. ` +
123
- `The remote browser needs a secure tunnel to reach your local dev server. ` +
124
- `(Detail: ${msg})${diag}`);
123
+ throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}: ${msg}${diag}`);
125
124
  }
126
125
  provision = tunnel;
127
126
  ctx = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.tunnels.revoke(tunnel), tunnel);
@@ -151,15 +150,15 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
151
150
  if (!health.healthy) {
152
151
  const payload = {
153
152
  error: 'TunnelTrafficBlocked',
154
- message: `Tunnel was established but traffic isn't reaching the dev server. ${health.detail ?? ''} Common causes: dev server binds to 0.0.0.0 or ::1 but not 127.0.0.1; dev server crashed; firewall.`,
153
+ message: `Tunnel established; a request through it to 127.0.0.1:${extractLocalhostPort(ctx.originalUrl)} failed: ${health.detail ?? health.code}.`,
155
154
  detail: {
156
155
  code: health.code,
157
156
  status: health.status,
158
- ngrokErrorCode: health.ngrokErrorCode,
157
+ tunnelErrorCode: health.tunnelErrorCode,
159
158
  elapsedMs: health.elapsedMs,
160
159
  },
161
160
  };
162
- logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.ngrokErrorCode ?? ''} in ${health.elapsedMs}ms`);
161
+ logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.tunnelErrorCode ?? ''} in ${health.elapsedMs}ms`);
163
162
  // Evict ONLY on a code proving the endpoint is gone; every other failure
164
163
  // keeps the tunnel we are already paying for, because a teardown+
165
164
  // re-provision costs two billed hours and this probe cannot tell a dead
@@ -186,9 +185,7 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
186
185
  return client.workflows.findTemplateBySlug(templateSlug);
187
186
  });
188
187
  if (!templateUuid) {
189
- throw new Error(`Crawl Workflow Template not found (slug "${templateSlug}"). ` +
190
- `Ensure the backend has that template seeded and accessible ` +
191
- `(GET /api/v1/workflows/?slug=${templateSlug}).`);
188
+ throw new Error(`Crawl workflow template not found (slug "${templateSlug}").`);
192
189
  }
193
190
  // --- Build contextData + env ---
194
191
  const contextData = {
@@ -293,13 +290,29 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
293
290
  targetUrl: ctx.originalUrl,
294
291
  durationMs: finalExecution.durationMs ?? duration,
295
292
  };
296
- const outcome = finalExecution.state?.outcome;
297
- if (outcome !== undefined && outcome !== null)
298
- responsePayload.outcome = outcome;
293
+ // Map onto the user-facing enum instead of relaying raw. This path never
294
+ // went through adaptVerdict, so it would otherwise emit whatever the
295
+ // backend sent — including retired non-answers like 'inconclusive'.
296
+ const rawOutcome = finalExecution.state?.outcome;
297
+ if (finalExecution.verdict?.skipped === true && !finalExecution.verdict.outcome) {
298
+ // platform-98fv.16: the run never attempted a test — no verdict, the
299
+ // backend's recorded reason verbatim. Never the raw run outcome.
300
+ responsePayload.outcome = null;
301
+ responsePayload.skipped = true;
302
+ const skipReason = finalExecution.verdict.reason;
303
+ if (typeof skipReason === 'string' && skipReason.trim() !== '')
304
+ responsePayload.skipReason = skipReason;
305
+ }
306
+ else if (rawOutcome !== undefined && rawOutcome !== null) {
307
+ responsePayload.outcome = toUserFacingOutcome(rawOutcome);
308
+ if (responsePayload.outcome === 'error' && !finalExecution.errorMessage) {
309
+ responsePayload.reason = noVerdictReason(rawOutcome);
310
+ }
311
+ }
299
312
  if (finalExecution.errorMessage)
300
313
  responsePayload.errorMessage = finalExecution.errorMessage;
301
- if (finalExecution.errorInfo?.failedNodeId)
302
- responsePayload.failedNode = finalExecution.errorInfo.failedNodeId;
314
+ if (finalExecution.errorInfo)
315
+ responsePayload.errorInfo = finalExecution.errorInfo;
303
316
  if (executeResponse.resolvedEnvironmentId)
304
317
  responsePayload.resolvedEnvironmentId = executeResponse.resolvedEnvironmentId;
305
318
  if (executeResponse.resolvedCredentialId)
@@ -310,34 +323,29 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
310
323
  if (finalExecution.browserSession) {
311
324
  responsePayload.browserSession = finalExecution.browserSession;
312
325
  }
313
- // Extract crawl metrics from surfer.crawl node (absent in older graph shapes)
314
- const crawlNode = nodes.find(n => n.nodeType === 'surfer.crawl');
315
- if (crawlNode?.outputData) {
316
- const d = crawlNode.outputData;
317
- responsePayload.crawlSummary = {
318
- pagesDiscovered: d.pagesDiscovered,
319
- actionsExecuted: d.actionsExecuted,
320
- stepsTaken: d.stepsTaken,
321
- transitionsRecorded: d.transitionsRecorded,
322
- knowledgeGraphStates: d.knowledgeGraphStates,
323
- success: d.success,
324
- ...(d.error ? { error: d.error } : {}),
325
- };
326
- }
327
- // Extract KG import result from knowledge_graph.import node (absent in older graph shapes)
328
- const kgNode = nodes.find(n => n.nodeType === 'knowledge_graph.import');
329
- if (kgNode?.outputData) {
330
- const d = kgNode.outputData;
331
- responsePayload.knowledgeGraph = {
332
- imported: !d.skipped,
333
- skipped: d.skipped ?? false,
334
- reason: d.reason ?? '',
335
- edgesImported: d.edgesImported ?? 0,
336
- statesImported: d.statesImported ?? 0,
337
- knowledgeGraphId: d.knowledgeGraphId ?? '',
338
- ...(Array.isArray(d.importErrors) && d.importErrors.length > 0 ? { importErrors: d.importErrors } : {}),
339
- };
340
- }
326
+ // Node output, relayed under the backend's own keys. Only the listed keys,
327
+ // and only when the node reported them: a missing count is absent, never a
328
+ // fabricated 0 / '' that reads as "the backend said zero".
329
+ const pick = (src, keys) => {
330
+ if (!src)
331
+ return undefined;
332
+ const out = {};
333
+ for (const k of keys)
334
+ if (src[k] !== undefined && src[k] !== null)
335
+ out[k] = src[k];
336
+ return out;
337
+ };
338
+ const crawlSummary = pick(nodes.find(n => n.nodeType === 'surfer.crawl')?.outputData, [
339
+ 'pagesDiscovered', 'actionsExecuted', 'stepsTaken', 'transitionsRecorded',
340
+ 'knowledgeGraphStates', 'success', 'error',
341
+ ]);
342
+ if (crawlSummary)
343
+ responsePayload.crawlSummary = crawlSummary;
344
+ const knowledgeGraph = pick(nodes.find(n => n.nodeType === 'knowledge_graph.import')?.outputData, [
345
+ 'skipped', 'reason', 'edgesImported', 'statesImported', 'knowledgeGraphId', 'importErrors',
346
+ ]);
347
+ if (knowledgeGraph)
348
+ responsePayload.knowledgeGraph = knowledgeGraph;
341
349
  logger.toolComplete('trigger_crawl', duration);
342
350
  // Bead 0bq: final progress is emitted INSIDE pollExecution's onUpdate when
343
351
  // terminal status is detected. Emitting it here would race the response
@@ -134,10 +134,10 @@ export async function updateEnvironmentHandler(input, _context) {
134
134
  if (warnings.length > 0)
135
135
  payload.credentialWarnings = warnings;
136
136
  if (input.authorizedCredentialHosts !== undefined) {
137
- const hostsWarning = checkAuthorizedCredentialHostsEcho(input.authorizedCredentialHosts, environment?.authorizedCredentialHosts, 'update');
137
+ const hostsWarning = checkAuthorizedCredentialHostsEcho(input.authorizedCredentialHosts, environment?.authorizedCredentialHosts);
138
138
  if (hostsWarning) {
139
139
  payload.authorizedCredentialHostsWarning = hostsWarning;
140
- logger.warn(`update_environment: ${hostsWarning.message}`);
140
+ logger.warn(`update_environment: authorizedCredentialHosts not persisted`, hostsWarning);
141
141
  }
142
142
  }
143
143
  logger.toolComplete('update_environment', Date.now() - start);
package/dist/index.js CHANGED
@@ -271,7 +271,7 @@ function safeLog(level, message, meta) {
271
271
  */
272
272
  async function gracefulShutdown(signal) {
273
273
  safeLog('info', `Received ${signal}, shutting down gracefully`);
274
- const { tunnelManager } = await import('./services/ngrok/tunnelManager.js');
274
+ const { tunnelManager } = await import('./services/tunnel/tunnelManager.js');
275
275
  await tunnelManager.stopAllTunnels().catch((err) => safeLog('warn', 'stopAllTunnels failed during shutdown', { error: String(err) }));
276
276
  await Telemetry.shutdown();
277
277
  process.exit(0);
@@ -83,8 +83,9 @@ export function isDockerEnv() {
83
83
  // @radically-straightforward/caddy is a dependency (package.json's "caddy"
84
84
  // field pins the exact version — see its postinstall) that downloads Caddy
85
85
  // from the project's own GitHub releases and drops it at
86
- // node_modules/.bin/caddy(.exe). Same pattern this repo already uses for the
87
- // ngrok binary (the "ngrok" npm package's own postinstall). Pinned rather
86
+ // node_modules/.bin/caddy(.exe). It is now the ONLY binary this package pulls
87
+ // down at install time — the ngrok package, whose postinstall fetched the ngrok
88
+ // agent the same way, is gone. Pinned rather
88
89
  // than "latest" deliberately: a real config incompatibility with a Caddy
89
90
  // version (--adapter json rejected by 2.11.3) was found and fixed during
90
91
  // development of this file — "latest" silently shipping a breaking change
@@ -273,7 +274,7 @@ export function buildPatchBody(target, inDocker) {
273
274
  upstreams: [{ dial }],
274
275
  };
275
276
  if (isHttpsLocal) {
276
- // Local self-signed certs — doesn't touch the public leg's real ngrok TLS.
277
+ // Local self-signed certs — doesn't touch the public leg's real TLS.
277
278
  body.transport = { protocol: 'http', tls: { insecure_skip_verify: true } };
278
279
  }
279
280
  return body;
@@ -370,7 +371,7 @@ export class CaddyProxyManager {
370
371
  }
371
372
  // Network-level failure against a previously-healthy admin port —
372
373
  // the process is presumed dead. Exactly one respawn-and-retry, then
373
- // propagate uncaught — no loop (matches ngrokAgentSession.ts's
374
+ // propagate uncaught — no loop (matches the transport's
374
375
  // onTerminated philosophy: lazy, bounded, next-call-triggered).
375
376
  logger.warn(`Caddy admin API unreachable on port ${adminPort} — assuming process died, respawning once: ${err}`);
376
377
  this.markDead();
@@ -520,7 +521,7 @@ export class CaddyProxyManager {
520
521
  * successfully-bound proxy port (if any); attempt 2 falls back to a fresh
521
522
  * one. `CaddyPortReclaimError` means BOTH attempts failed — Caddy is fully
522
523
  * down. `onPortChanged` fires on the "succeeded but moved" case, which is
523
- * the signal TunnelManager needs to evict the now-orphaned ngrok tunnel.
524
+ * the signal TunnelManager needs to evict the now-orphaned tunnel.
524
525
  */
525
526
  async doStart() {
526
527
  sweepOrphanedConfigs(this.configDir);
@@ -593,7 +594,7 @@ export class CaddyProxyManager {
593
594
  }
594
595
  }
595
596
  /** Restart policy: lazy, bounded, next-call-triggered — no background
596
- * watchdog (matches ngrokAgentSession.ts's onTerminated philosophy). This
597
+ * watchdog (matches the transport's onDead philosophy). This
597
598
  * handler only marks state as dead; it never itself triggers a respawn. */
598
599
  installExitHandler(child) {
599
600
  child.once('exit', (code, signal) => {
@@ -1,3 +1,4 @@
1
+ import { toUserFacingOutcome } from './verdictAdapter.js';
1
2
  import { createWorkflowsService } from "./workflows.js";
2
3
  import { createTunnelsService } from "./tunnels.js";
3
4
  import { AxiosTransport } from "../utils/axiosTransport.js";
@@ -298,7 +299,8 @@ export class DebuggAIServerClient {
298
299
  if (!this.tx)
299
300
  throw new Error('Client not initialized — call init() first');
300
301
  const response = await this.tx.delete(`api/v1/environments/${envUuid}/sessions/`, { params: filters });
301
- return { invalidated: response?.invalidated ?? 0 };
302
+ // absent → null: a count the backend did not report is not a reported 0
303
+ return { invalidated: response?.invalidated ?? null };
302
304
  }
303
305
  /**
304
306
  * Fetch a single environment by UUID. Throws AxiosError with status 404 if not found.
@@ -473,15 +475,6 @@ export class DebuggAIServerClient {
473
475
  isActive: c.isActive,
474
476
  };
475
477
  }
476
- /**
477
- * Revoke an ngrok API key by its key ID.
478
- * Call this after workflow execution completes to clean up the short-lived key.
479
- */
480
- async revokeNgrokKey(ngrokKeyId) {
481
- if (!this.tx)
482
- throw new Error('Client not initialized — call init() first');
483
- await this.tx.post('api/v1/ngrok/revoke/', { ngrokKeyId });
484
- }
485
478
  // ── E2E Suite Management ──────────────────────────────────────────────────
486
479
  async createTestSuite(input) {
487
480
  if (!this.tx)
@@ -509,8 +502,8 @@ export class DebuggAIServerClient {
509
502
  uuid: s.uuid,
510
503
  name: s.name,
511
504
  description: s.description ?? null,
512
- runStatus: s.runStatus ?? s.run_status ?? 'NEVER_RUN',
513
- testsCount: s.testsCount ?? s.tests_count ?? 0,
505
+ runStatus: s.runStatus ?? s.run_status ?? null,
506
+ testsCount: s.testsCount ?? s.tests_count ?? null,
514
507
  passRate: s.passRate ?? s.pass_rate ?? null,
515
508
  lastRunAt: s.lastRunAt ?? s.last_run_at ?? null,
516
509
  })),
@@ -545,7 +538,7 @@ export class DebuggAIServerClient {
545
538
  agentTaskDescription: t.agentTaskDescription ?? t.agent_task_description ?? '',
546
539
  suite: t.suite ?? input.suiteUuid,
547
540
  project: t.project ?? input.projectUuid,
548
- runCount: t.runCount ?? t.run_count ?? 0,
541
+ runCount: t.runCount ?? t.run_count ?? null,
549
542
  };
550
543
  }
551
544
  async updateTestCase(testUuid, patch) {
@@ -581,7 +574,7 @@ export class DebuggAIServerClient {
581
574
  const s = await this.tx.post(`api/v1/test-suites/${suiteUuid}/run/`, body);
582
575
  return {
583
576
  suiteUuid,
584
- runStatus: s?.runStatus ?? s?.run_status ?? 'PENDING',
577
+ runStatus: s?.runStatus ?? s?.run_status ?? null,
585
578
  testsTriggered: (s?.tests ?? []).length,
586
579
  };
587
580
  }
@@ -593,7 +586,7 @@ export class DebuggAIServerClient {
593
586
  return {
594
587
  uuid: s.uuid,
595
588
  name: s.name,
596
- runStatus: s.runStatus ?? s.run_status ?? 'NEVER_RUN',
589
+ runStatus: s.runStatus ?? s.run_status ?? null,
597
590
  testsCount: tests.length,
598
591
  passRate: s.passRate ?? s.pass_rate ?? null,
599
592
  lastRunAt: s.lastRunAt ?? s.last_run_at ?? null,
@@ -603,14 +596,18 @@ export class DebuggAIServerClient {
603
596
  return {
604
597
  uuid: t.uuid,
605
598
  name: t.name,
606
- runCount: t.runCount ?? t.run_count ?? 0,
607
- passedRunsCount: t.passedRunsCount ?? t.passed_runs_count ?? 0,
608
- failedRunsCount: t.failedRunsCount ?? t.failed_runs_count ?? 0,
599
+ runCount: t.runCount ?? t.run_count ?? null,
600
+ passedRunsCount: t.passedRunsCount ?? t.passed_runs_count ?? null,
601
+ failedRunsCount: t.failedRunsCount ?? t.failed_runs_count ?? null,
609
602
  passRate: t.passRate ?? t.pass_rate ?? null,
610
603
  lastRun: lastRun ? {
611
604
  uuid: lastRun.uuid,
612
605
  status: lastRun.status,
613
- outcome: lastRun.outcome,
606
+ // the user-facing verdict allowlist, same as every other surface;
607
+ // a run with no outcome yet keeps its null
608
+ outcome: typeof lastRun.outcome === 'string' && lastRun.outcome.trim() !== ''
609
+ ? toUserFacingOutcome(lastRun.outcome)
610
+ : lastRun.outcome ?? null,
614
611
  executionTime: lastRun.executionTime ?? lastRun.execution_time ?? null,
615
612
  timestamp: lastRun.timestamp,
616
613
  } : null,
@@ -623,8 +620,8 @@ export class DebuggAIServerClient {
623
620
  uuid: s.uuid,
624
621
  name: s.name,
625
622
  description: s.description ?? null,
626
- runStatus: s.runStatus ?? s.run_status ?? 'NEVER_RUN',
627
- testsCount: s.testsCount ?? s.tests_count ?? 0,
623
+ runStatus: s.runStatus ?? s.run_status ?? null,
624
+ testsCount: s.testsCount ?? s.tests_count ?? null,
628
625
  };
629
626
  }
630
627
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * One websocket per tunnel to the relayUrl a provision response supplies,
5
5
  * speaking the wire protocol in services/tunnel/protocol/ (spec: bead
6
- * debugg_ai_mcp-xkoh.1.2). It replaces the `ngrok` package, which downloads and
6
+ * debugg_ai_mcp-xkoh.1.2). It replaced the `ngrok` package, which downloaded and
7
7
  * spawns a native agent; `ws` is the only new dependency and is pure JS.
8
8
  *
9
9
  * This file owns exactly four things — the socket, the reconnect policy, the
@@ -324,7 +324,7 @@ class DebuggTransport {
324
324
  });
325
325
  socket.on('error', () => {
326
326
  if (!established) {
327
- // The ERR_NGROK_8012 analogue: the tunnel is fine, the app refused.
327
+ // The tunnel is fine; the app refused the connection.
328
328
  // The server renders DEBUGG_TUNNEL_UPSTREAM_REFUSED from this code and
329
329
  // tunnelDisposition keeps the tunnel.
330
330
  stream.reset(ErrorCode.UPSTREAM_UNREACHABLE);
@@ -359,9 +359,9 @@ function terminal(message) {
359
359
  function handshakeFailureError(state, status, supported, retryable) {
360
360
  switch (status) {
361
361
  case 401:
362
- // The word "authtoken" is load-bearing: TunnelManager matches it to
363
- // render its "invalid auth token" message, the same one an ngrok auth
364
- // failure produces.
362
+ // The word "authtoken" is load-bearing: TunnelManager's connect ladder
363
+ // matches it to stop retrying and render its "invalid auth token"
364
+ // message.
365
365
  return terminal('the debugg tunnel server rejected the authtoken (HTTP 401) — the tunnel key is invalid, ' +
366
366
  'expired or revoked. A new one is provisioned on the next call.');
367
367
  case 400:
@@ -379,10 +379,10 @@ function handshakeFailureError(state, status, supported, retryable) {
379
379
  }
380
380
  }
381
381
  /**
382
- * A connection-level failure reaching the relay. This is the one place where
383
- * the debugg transport can be WORSE than ngrok for a user — ngrok's agent
384
- * honours HTTPS_PROXY and this client does not — so the message says plainly
385
- * what has to be allowed instead of reading as a generic tunnel error.
382
+ * A connection-level failure reaching the relay. This client does NOT honour
383
+ * HTTPS_PROXY (the retired ngrok agent did, which is the one capability lost
384
+ * in the cutover — design §5 "open gap"), so the message says plainly what has
385
+ * to be allowed instead of reading as a generic tunnel error.
386
386
  */
387
387
  function egressError(state, detail, code) {
388
388
  const relayHost = hostOf(state.relayUrl);
@@ -163,14 +163,14 @@ export const HandshakeError = Object.freeze({
163
163
  });
164
164
  // ── Server error markers (server emits, client parses) ───────────────────────
165
165
  export const TunnelErrorMarker = Object.freeze({
166
- /** No client connected — the ERR_NGROK_3200 analogue. */
166
+ /** No client connected — 404, matching what the retired ngrok edge served. */
167
167
  OFFLINE: 'DEBUGG_TUNNEL_OFFLINE',
168
168
  /** Unknown or revoked tunnel id. */
169
169
  UNKNOWN: 'DEBUGG_TUNNEL_UNKNOWN',
170
- /** The client could not dial the local app — the ERR_NGROK_8012 analogue. */
170
+ /** The client could not dial the local app — 502. */
171
171
  UPSTREAM_REFUSED: 'DEBUGG_TUNNEL_UPSTREAM_REFUSED',
172
172
  });
173
- /** HTTP status each marker is served with, chosen to match the ngrok codes they replace. */
173
+ /** HTTP status each marker is served with, chosen to match the codes they replaced. */
174
174
  export const MARKER_HTTP_STATUS = Object.freeze({
175
175
  [TunnelErrorMarker.OFFLINE]: 404,
176
176
  [TunnelErrorMarker.UNKNOWN]: 404,
@@ -1,15 +1,34 @@
1
1
  /**
2
- * TunnelTransport — the one step of tunnel creation that differs between ngrok
3
- * and the debugg tunnel server.
2
+ * TunnelTransport — the boundary between tunnel LIFECYCLE (tunnelManager.ts:
3
+ * who owns a tunnel, when it is reused, when it is torn down) and tunnel
4
+ * TRANSPORT (debuggTransport.ts: the websocket mux that actually carries
5
+ * bytes).
4
6
  *
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
+ * This started life as a two-implementation seam so the ngrok client and the
8
+ * debugg client could run side by side through the rollout. ngrok is gone and
9
+ * there is exactly one implementation now, but the interface is KEPT and the
10
+ * two-implementation machinery around it is not — see the note below.
11
+ *
12
+ * WHY KEEP AN INTERFACE WITH ONE IMPLEMENTATION: it is the injection point
13
+ * (`tunnelManager.transport`) every tunnel-lifecycle test drives, in the same
14
+ * spirit as `caddyFactory`. Without it, testing eviction, the retry ladder or
15
+ * the idle timer means standing up a real websocket against a real relay.
16
+ * It also keeps debuggTransport's ~600 lines of framing, flow control and
17
+ * reconnect out of TunnelManager, which is the reason the file boundary is
18
+ * worth having regardless of how many implementations exist.
19
+ *
20
+ * WHAT WAS REMOVED WITH ngrok, because it was ceremony the moment the second
21
+ * implementation went away:
22
+ * - `TunnelTransportKind` and the `Record<kind, TunnelTransport>` registry —
23
+ * a map with one key, keyed by a union with one member.
24
+ * - `prepare()`, `resetAfterFailedAttempt()` and `onAllTunnelsStopped()` —
25
+ * three optional hooks that existed ONLY for the ngrok agent process
26
+ * (pre-warm, agent reset between retries, module reset when the agent may
27
+ * have exited). debugg has no out-of-process agent, so every one of them
28
+ * was a no-op on the only surviving implementation.
7
29
  *
8
30
  * 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.
31
+ * the per-session Caddy instance, PortLock, URL rewriting, the retry ladder and
32
+ * the idle timer (docs/debugg-tunnel-server-design-2026-09-19.md §5).
14
33
  */
15
34
  export {};
@@ -6,27 +6,27 @@
6
6
  * - DEBUGG_TUNNEL_FAULT_MODE env var explicitly set
7
7
  *
8
8
  * Modes (comma-separated, parseable by parseFaultMode):
9
- * fail-connect-N:<count> — fail the first <count> ngrok.connect() attempts
9
+ * fail-connect-N:<count> — fail the first <count> transport connect() attempts
10
10
  * empty-url-N:<count> — return empty URL from first <count> connect() attempts
11
11
  * delay-connect:<ms> — sleep <ms> before each connect() call
12
- * inspector-unreachable:1 — the local ngrok agent inspector observes nothing
13
- * (bead lc62: proves reconciliation is add-only, i.e.
14
- * a blind inspector cannot cost a re-provision)
15
- * inspector-adopt:<port> — the inspector reports one synthetic live tunnel on
16
- * <port>, exercising re-adoption end to end without
17
- * provisioning a real, billable tunnel
18
12
  *
19
13
  * Examples:
20
14
  * DEBUGG_TUNNEL_FAULT_MODE=fail-connect-N:2
21
15
  * DEBUGG_TUNNEL_FAULT_MODE=delay-connect:2000,fail-connect-N:1
22
- * DEBUGG_TUNNEL_FAULT_MODE=inspector-adopt:3000
16
+ *
17
+ * Two further modes — `inspector-unreachable` and `inspector-adopt` — went
18
+ * away with the ngrok agent inspector they drove. Both existed to exercise
19
+ * re-adoption of a tunnel already registered with the local ngrok agent, a
20
+ * mechanism that the per-session-tunnel cutover retired ("no sharing, ever")
21
+ * and that the debugg transport has no equivalent of: it owns its own socket
22
+ * and there is no out-of-process agent holding tunnels to discover.
23
23
  */
24
24
  export function parseFaultMode(raw) {
25
25
  if (!raw)
26
26
  return null;
27
27
  const mode = {};
28
28
  for (const token of raw.split(',').map((s) => s.trim()).filter(Boolean)) {
29
- const m = token.match(/^(fail-connect-N|empty-url-N|delay-connect|inspector-unreachable|inspector-adopt):(\d+)$/);
29
+ const m = token.match(/^(fail-connect-N|empty-url-N|delay-connect):(\d+)$/);
30
30
  if (!m)
31
31
  continue;
32
32
  const [, name, valStr] = m;
@@ -37,12 +37,6 @@ export function parseFaultMode(raw) {
37
37
  mode.emptyUrlN = val;
38
38
  else if (name === 'delay-connect')
39
39
  mode.delayConnectMs = val;
40
- // Value-less switch, but kept in the `name:<digits>` grammar so one regex
41
- // covers every mode. Any non-zero value turns it on.
42
- else if (name === 'inspector-unreachable')
43
- mode.inspectorUnreachable = val !== 0;
44
- else if (name === 'inspector-adopt')
45
- mode.inspectorAdoptPort = val;
46
40
  }
47
41
  return Object.keys(mode).length > 0 ? mode : null;
48
42
  }