@debugg-ai/debugg-ai-mcp 4.4.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,9 +13,10 @@ AI-powered browser testing via the [Model Context Protocol](https://modelcontext
13
13
  **Testing `http://localhost:...` URLs requires the `caddy` binary** — `check_app_in_browser`,
14
14
  `probe_page`, and `trigger_crawl` tunnel localhost targets through a local Caddy reverse proxy.
15
15
  This installs automatically: the `@radically-straightforward/caddy` npm dependency downloads a
16
- pinned Caddy release for your platform during `npm install`/`npx`, same as this project already
17
- does for the `ngrok` binary — nothing to install yourself in the normal case. If that download
18
- never ran (`npm install --ignore-scripts`, an offline/air-gapped install), point `CADDY_BIN` at
16
+ pinned Caddy release for your platform during `npm install`/`npx` — nothing to install yourself in
17
+ the normal case. It is now the **only** binary this package downloads; the tunnel client itself is
18
+ pure TypeScript — it replaced the `ngrok` package, which fetched the ngrok agent.
19
+ If that download never ran (`npm install --ignore-scripts`, an offline/air-gapped install), point `CADDY_BIN` at
19
20
  your own install (`brew install caddy` / `apt install caddy` / see
20
21
  [caddyserver.com/docs/install](https://caddyserver.com/docs/install)) — missing it surfaces as a
21
22
  clear error on the first localhost-URL call, not a silent hang. Public-URL calls, every
@@ -63,7 +64,7 @@ The server exposes **8** tools: three **Browser** tools plus one **action-based*
63
64
 
64
65
  #### `check_app_in_browser`
65
66
 
66
- Runs an AI browser agent against your app. The agent navigates, interacts, and reports back with screenshots. Localhost URLs are auto-tunneled via ngrok.
67
+ Runs an AI browser agent against your app. The agent navigates, interacts, and reports back with screenshots. Localhost URLs are auto-tunneled through the debugg tunnel server.
67
68
 
68
69
  | Parameter | Type | Description |
69
70
  |-----------|------|-------------|
@@ -254,7 +255,8 @@ clients without resource support keep using the tools.
254
255
  ### Security invariants
255
256
 
256
257
  - Passwords are write-only. They never appear in any response body from any tool.
257
- - Tunnel URLs (`*.ngrok.debugg.ai`) are stripped from all browser-agent responses, including agent-authored text.
258
+ - Tunnel URLs (`*.tunnel.debugg.ai`, and the retired `*.ngrok.debugg.ai` that historical runs still
259
+ reference) are stripped from all browser-agent responses, including agent-authored text.
258
260
  - 404s from the backend surface as `isError: true` with `{error: 'NotFound', ...}`, never as thrown exceptions.
259
261
  - Missing `DEBUGGAI_API_KEY` surfaces as a structured tool error on first invocation — the server still registers and lists tools normally.
260
262
 
@@ -341,11 +343,11 @@ flow against the advertised authorization server. The bearer is request-scoped
341
343
 
342
344
  stdio installs need none of these.
343
345
 
344
- **Multi-replica deployments (go/no-go before rollout):** tunnel state (the ngrok session tunnel,
346
+ **Multi-replica deployments (go/no-go before rollout):** tunnel state (the session tunnel,
345
347
  its Caddy instance, and its port-route lock) is in-process, keyed per caller by a hash of the
346
348
  bearer token — there is no cross-process coordination. Running several replicas behind a plain
347
349
  round-robin load balancer means one caller's calls can land on different replicas and mint one
348
- tunnel **per replica they hit** instead of one for the whole session (extra ngrok cost, bounded by
350
+ tunnel **per replica they hit** instead of one for the whole session (bounded by
349
351
  replica count, self-healing via the existing 55-minute idle auto-shutoff — never a cross-session
350
352
  correctness bug, since any single tool call stays on one replica for its whole duration). To get
351
353
  the intended "one tunnel per session" behavior on a multi-replica HTTP deployment, configure
@@ -106,7 +106,7 @@ export async function probePageHandler(input, context, rawProgressCallback) {
106
106
  // Tunnel keys we provisioned this call (for cleanup if creation fails after key acquired).
107
107
  // The provisions this call acquired, in target order. Provisions rather than
108
108
  // bare key ids because revoking follows the transport the backend picked
109
- // (ngrok keys and debugg tunnels have different endpoints).
109
+ // (it routes to the tunnel's own revoke endpoint).
110
110
  const acquiredProvisions = [];
111
111
  // Progress budget: 1 pre-flight + 1 template + 1 execute + N per-target captures + 1 done
112
112
  const TOTAL_STEPS = 3 + input.targets.length + 1;
@@ -257,7 +257,7 @@ export async function probePageHandler(input, context, rawProgressCallback) {
257
257
  detail: {
258
258
  code: health.code,
259
259
  status: health.status,
260
- ngrokErrorCode: health.ngrokErrorCode,
260
+ tunnelErrorCode: health.tunnelErrorCode,
261
261
  elapsedMs: health.elapsedMs,
262
262
  },
263
263
  };
@@ -6,7 +6,7 @@ import { disposeUnhealthyTunnel } from '../utils/tunnelDisposition.js';
6
6
  import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js';
7
7
  import { extractLocalhostPort } from '../utils/urlParser.js';
8
8
  import { buildContext, sanitizeResponseUrls } from '../utils/tunnelContext.js';
9
- import { tunnelManager } from '../services/ngrok/tunnelManager.js';
9
+ import { tunnelManager } from '../services/tunnel/tunnelManager.js';
10
10
  import { config } from '../config/index.js';
11
11
  import { resolveProject, resolveTestSuite } from '../utils/resolveProject.js';
12
12
  const logger = new Logger({ module: 'runTestSuiteHandler' });
@@ -68,7 +68,7 @@ export async function runTestSuiteHandler(input, _context) {
68
68
  // and its PortLock would give this handler no real protection —
69
69
  // the lock would release back to contention seconds after
70
70
  // triggering a suite that goes on to use the port for much longer.
71
- // So it gets its OWN tunnel, dialing ngrok directly, bypassing
71
+ // So it gets its OWN tunnel, dialing the app directly, bypassing
72
72
  // Caddy/PortLock entirely (never deduped/reused — always fresh).
73
73
  let tunnel;
74
74
  try {
@@ -88,7 +88,7 @@ export async function runTestSuiteHandler(input, _context) {
88
88
  const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
89
89
  return errorResp('TunnelCreationFailed', `Tunnel creation failed for ${input.targetUrl}. (Detail: ${msg})`);
90
90
  }
91
- // Health probe — catches ERR_NGROK_8012 and bind mismatches before
91
+ // Health probe — catches an upstream-refused tunnel and bind mismatches before
92
92
  // the remote agent wastes steps trying to reach the server.
93
93
  if (dedicated.url) {
94
94
  const health = await probeTunnelHealth(dedicated.url);
@@ -104,7 +104,7 @@ export async function runTestSuiteHandler(input, _context) {
104
104
  // revoking a live tunnel's credential would kill what we preserved.
105
105
  // (On the eviction branch markTunnelDead already revokes it.)
106
106
  tunnelId = dedicated.tunnelId;
107
- return errorResp('TunnelTrafficBlocked', `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`, { code: health.code, ngrokErrorCode: health.ngrokErrorCode, elapsedMs: health.elapsedMs });
107
+ return errorResp('TunnelTrafficBlocked', `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`, { code: health.code, tunnelErrorCode: health.tunnelErrorCode, elapsedMs: health.elapsedMs });
108
108
  }
109
109
  }
110
110
  effectiveTargetUrl = dedicated.url;
@@ -125,7 +125,7 @@ export async function runTestSuiteHandler(input, _context) {
125
125
  // at all — safe only by accident of `result`'s narrow return type never
126
126
  // having carried a tunnel hostname in practice. Add the same defensive
127
127
  // pass every other handler already runs, so a future backend field that
128
- // echoes back the (dedicated, ngrok-direct) tunnel URL can never leak it
128
+ // echoes back the (dedicated, app-direct) tunnel URL can never leak it
129
129
  // to a caller who only knows their own localhost address.
130
130
  const sanitizedPayload = sanitizeCtx ? sanitizeResponseUrls(responsePayload, sanitizeCtx) : responsePayload;
131
131
  return {
@@ -16,7 +16,7 @@ import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, acqui
16
16
  import { randomUUID } from 'node:crypto';
17
17
  import { detectRepoName } from '../utils/gitContext.js';
18
18
  import { disposeUnhealthyTunnel } from '../utils/tunnelDisposition.js';
19
- import { probeLocalPort, probeTunnelHealth, extractNgrokErrorCode } from '../utils/localReachability.js';
19
+ import { probeLocalPort, probeTunnelHealth, extractTunnelErrorCode } from '../utils/localReachability.js';
20
20
  import { extractLocalhostPort } from '../utils/urlParser.js';
21
21
  import { getCachedTemplateUuid, getCachedProjectUuid, invalidateTemplateCache, invalidateProjectCache, } from '../utils/handlerCaches.js';
22
22
  import { isTransientWorkflowError, transientReasonTag } from '../utils/transientErrors.js';
@@ -35,12 +35,12 @@ function getMaxTransientRetries() {
35
35
  return 1;
36
36
  return Math.min(n, 3);
37
37
  }
38
- // Bug z15n: scan run evidence for ngrok's interstitial marker. The marker is a
38
+ // Bug z15n: scan run evidence for the tunnel server's interstitial marker. It is a
39
39
  // stable, recognizable string the REMOTE BROWSER saw — positive evidence that it
40
40
  // hit our tunnel's error page rather than the user's app. Non-string parts are
41
41
  // serialized (the action trace is the usual carrier); a part we can't serialize
42
42
  // is simply skipped — it is not evidence either way.
43
- function findNgrokErrorMarker(parts) {
43
+ function findTunnelErrorMarker(parts) {
44
44
  for (const part of parts) {
45
45
  if (part === undefined || part === null || part === '')
46
46
  continue;
@@ -51,7 +51,7 @@ function findNgrokErrorMarker(parts) {
51
51
  catch {
52
52
  continue;
53
53
  }
54
- const code = extractNgrokErrorCode(text);
54
+ const code = extractTunnelErrorCode(text);
55
55
  if (code)
56
56
  return code;
57
57
  }
@@ -202,7 +202,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
202
202
  // --- Tunnel: reuse existing or provision a fresh one ---
203
203
  if (ctx.isLocalhost) {
204
204
  // Bead 1om: pre-flight local port probe BEFORE committing to backend
205
- // provision + ngrok session. If the user's dev server isn't listening,
205
+ // provision + tunnel session. If the user's dev server isn't listening,
206
206
  // fail in ~1.5s with a structured error instead of burning 5 minutes
207
207
  // on a browser agent trying to reach a dead tunnel.
208
208
  const localPort = extractLocalhostPort(ctx.originalUrl);
@@ -251,7 +251,13 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
251
251
  }
252
252
  provision = tunnel;
253
253
  try {
254
- ctx = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
254
+ ctx = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.tunnels.revoke(tunnel),
255
+ // The provision IS the transport selection. Omitting it here is
256
+ // not a no-op: without it ensureTunnel gets no relayUrl or
257
+ // tunnelDomain at all, so the flagship tool cannot connect a
258
+ // tunnel the backend just issued. The other three
259
+ // handlers pass it; this one was missed.
260
+ tunnel);
255
261
  }
256
262
  catch (tunnelError) {
257
263
  const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
@@ -281,7 +287,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
281
287
  : undefined,
282
288
  });
283
289
  // Bead 1om: verify traffic actually flows through the tunnel. The
284
- // tunnel can be established (ngrok.connect returns OK) yet refuse
290
+ // tunnel can be established (connect returns OK) yet refuse
285
291
  // to forward traffic — e.g., IPv4/IPv6 bind mismatch, or the dev
286
292
  // server died between the pre-flight probe and here. Catch it now,
287
293
  // in ~1s, not via a 5-minute browser-agent false-pass.
@@ -294,11 +300,11 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
294
300
  detail: {
295
301
  code: health.code,
296
302
  status: health.status,
297
- ngrokErrorCode: health.ngrokErrorCode,
303
+ tunnelErrorCode: health.tunnelErrorCode,
298
304
  elapsedMs: health.elapsedMs,
299
305
  },
300
306
  };
301
- logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.ngrokErrorCode ?? ''} in ${health.elapsedMs}ms`);
307
+ logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.tunnelErrorCode ?? ''} in ${health.elapsedMs}ms`);
302
308
  // Evict ONLY on a code proving the endpoint is gone; every other
303
309
  // failure keeps the tunnel we are already paying for, because a
304
310
  // teardown+re-provision costs two billed hours and this probe cannot
@@ -702,7 +708,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
702
708
  // --- Post-hoc tunnel reclassification (bugs z15n, 4bui) ---
703
709
  // The pre-flight probe above proves the tunnel was alive when we handed it to
704
710
  // the remote browser — it can still die mid-run. When it does, the browser
705
- // lands on ngrok's ERR_NGROK_* interstitial and the backend, which only sees
711
+ // lands on the tunnel server's DEBUGG_TUNNEL_* interstitial and the backend, which only sees
706
712
  // "the page didn't contain what I was asked about", returns a normal 'fail'
707
713
  // whose reason blames the USER'S page for OUR dead tunnel (execution
708
714
  // a8f07747: 217s, 7 steps, failureCategory 'fail').
@@ -710,7 +716,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
710
716
  // THE MARKER IS REQUIRED (bug 4bui). Reclassifying overrides the backend's
711
717
  // verdict, so it demands positive evidence of the claim we are actually
712
718
  // making: that the remote browser hit OUR error page DURING THE RUN. Only
713
- // the ERR_NGROK_* marker recorded BY the run is evidence of that.
719
+ // the DEBUGG_TUNNEL_* marker recorded BY the run is evidence of that.
714
720
  //
715
721
  // The re-probe is NOT such evidence and can no longer trigger this on its
716
722
  // own. It answers a different question — "is the tunnel healthy NOW?" —
@@ -722,12 +728,12 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
722
728
  // independently returned a FALSE NETWORK_ERROR on healthy servers ~1 in 5
723
729
  // runs (bug k6yq), so the false positive was reachable with nothing
724
730
  // whatsoever wrong. (k6yq is now fixed: the cause was not the suspected DNS
725
- // race but an HTTP/2 GOAWAY from the ngrok edge on a freshly created
731
+ // race but a connection-level flake against a freshly created
726
732
  // tunnel, which probeTunnelHealth now retries. The marker requirement
727
733
  // stands on its own regardless — the probe never decides this.)
728
734
  //
729
735
  // Requiring the marker loses no coverage: a real mid-run death fires BOTH
730
- // arms (live-confirmed — re-probe NETWORK_ERROR *and* marker ERR_NGROK_3200),
736
+ // arms (live-confirmed — re-probe NETWORK_ERROR *and* an endpoint-gone marker),
731
737
  // so the marker alone still catches it. The probe is kept purely as
732
738
  // CORROBORATION in `detail` — it tells the caller whether the tunnel is
733
739
  // still down now or has since recovered. Per epic 56kd ("relay honestly,
@@ -735,7 +741,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
735
741
  // during the run is the relay inventing a cause.
736
742
  let tunnelFault;
737
743
  if (verdict.outcome === 'fail' && ctx.isLocalhost && ctx.tunnelId && ctx.targetUrl) {
738
- const marker = findNgrokErrorMarker([
744
+ const marker = findTunnelErrorMarker([
739
745
  verdict.reason,
740
746
  finalExecution.state?.error,
741
747
  finalExecution.errorMessage,
@@ -746,18 +752,18 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
746
752
  // never decides whether we reclassify, only what we report alongside it.
747
753
  const probe = await probeTunnelHealth(ctx.targetUrl).catch(() => undefined);
748
754
  if (marker) {
749
- tunnelFault = { probe, ngrokErrorCode: marker };
755
+ tunnelFault = { probe, tunnelErrorCode: marker };
750
756
  logger.warn(`Reclassifying backend 'fail' as an infrastructure fault for ${executionUuid}: ` +
751
757
  `the run recorded ${marker} (re-probe now: ` +
752
758
  `${probe ? (probe.healthy ? 'healthy — tunnel has since recovered' : probe.code) : 'unavailable'})`);
753
759
  }
754
760
  else if (probe && !probe.healthy) {
755
761
  // Deliberately NOT reclassifying. The tunnel looks unhealthy now, but
756
- // the run recorded no ngrok interstitial, so we have no evidence the
762
+ // the run recorded no tunnel interstitial, so we have no evidence the
757
763
  // browser ever saw one — the tunnel most likely died after the run (or
758
764
  // the probe flaked). Relay the backend's verdict and log the tension.
759
765
  logger.info(`Post-run tunnel re-probe for ${executionUuid} was unhealthy (${probe.code}) but the run ` +
760
- 'recorded no ERR_NGROK_* marker, so the browser reached the app during the run. Relaying ' +
766
+ 'recorded no DEBUGG_TUNNEL_* marker, so the browser reached the app during the run. Relaying ' +
761
767
  "the backend's verdict verbatim rather than blaming the tunnel.");
762
768
  }
763
769
  }
@@ -823,23 +829,23 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
823
829
  // The backend's original verdict is preserved verbatim, never swallowed.
824
830
  //
825
831
  // Bug 4bui: the stated cause leads with the MARKER, because the marker is the
826
- // evidence that justified getting here — the run itself recorded ngrok's
832
+ // evidence that justified getting here — the run itself recorded the tunnel's
827
833
  // interstitial. The probe only corroborates (and may legitimately say the
828
834
  // tunnel has recovered by now), so it rides in `detail`, never as the cause.
829
835
  if (tunnelFault) {
830
- const { probe, ngrokErrorCode } = tunnelFault;
836
+ const { probe, tunnelErrorCode } = tunnelFault;
831
837
  responsePayload.backendVerdict = { outcome: verdict.outcome, reason: verdict.reason };
832
838
  responsePayload.outcome = 'error';
833
839
  responsePayload.success = false;
834
840
  responsePayload.failureCategory = 'infrastructure';
835
841
  responsePayload.error = 'TunnelOfflineDuringRun';
836
842
  responsePayload.message =
837
- 'During this run the remote browser landed on our tunnel\'s ngrok error page instead of your ' +
843
+ 'During this run the remote browser landed on our tunnel\'s error page instead of your ' +
838
844
  'app, so the check evaluated our error page rather than your UI. This is an infrastructure ' +
839
845
  'fault on our side, not a failed check — retry it. The backend\'s original verdict is ' +
840
846
  'preserved under `backendVerdict`.';
841
847
  responsePayload.reason =
842
- `The run recorded ngrok's ${ngrokErrorCode} interstitial, so the remote browser reached ` +
848
+ `The run recorded our tunnel's ${tunnelErrorCode} interstitial, so the remote browser reached ` +
843
849
  'our tunnel error page instead of your app.' +
844
850
  (probe
845
851
  ? probe.healthy
@@ -848,7 +854,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
848
854
  `${probe.status ? ` (HTTP ${probe.status})` : ''}.)`
849
855
  : '');
850
856
  responsePayload.detail = {
851
- ngrokErrorCode,
857
+ tunnelErrorCode,
852
858
  probeCode: probe?.code,
853
859
  probeStatus: probe?.status,
854
860
  probeHealthy: probe?.healthy,
@@ -874,7 +880,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
874
880
  }
875
881
  // Backend release 2026-04-25: browser_session block on execution detail
876
882
  // carries presigned S3 URLs for HAR + console log + recording. Pass through
877
- // verbatim — sanitizeResponseUrls below only strips ngrok hosts so S3 URLs
883
+ // verbatim — sanitizeResponseUrls below only strips tunnel hosts so S3 URLs
878
884
  // are preserved. Resolves client-feedback items #1 (network) + #7 (console).
879
885
  if (finalExecution.browserSession) {
880
886
  responsePayload.browserSession = finalExecution.browserSession;
@@ -85,7 +85,7 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
85
85
  try {
86
86
  // --- Tunnel: reuse existing or provision a fresh one ---
87
87
  if (ctx.isLocalhost) {
88
- // Bead 1om: pre-flight local port probe BEFORE provision/ngrok/backend.
88
+ // Bead 1om: pre-flight local port probe BEFORE provision/tunnel/backend.
89
89
  const localPort = extractLocalhostPort(ctx.originalUrl);
90
90
  if (typeof localPort === 'number') {
91
91
  const probe = await probeLocalPort(localPort);
@@ -155,11 +155,11 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
155
155
  detail: {
156
156
  code: health.code,
157
157
  status: health.status,
158
- ngrokErrorCode: health.ngrokErrorCode,
158
+ tunnelErrorCode: health.tunnelErrorCode,
159
159
  elapsedMs: health.elapsedMs,
160
160
  },
161
161
  };
162
- logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.ngrokErrorCode ?? ''} in ${health.elapsedMs}ms`);
162
+ logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.tunnelErrorCode ?? ''} in ${health.elapsedMs}ms`);
163
163
  // Evict ONLY on a code proving the endpoint is gone; every other failure
164
164
  // keeps the tunnel we are already paying for, because a teardown+
165
165
  // re-provision costs two billed hours and this probe cannot tell a dead
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) => {
@@ -473,15 +473,6 @@ export class DebuggAIServerClient {
473
473
  isActive: c.isActive,
474
474
  };
475
475
  }
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
476
  // ── E2E Suite Management ──────────────────────────────────────────────────
486
477
  async createTestSuite(input) {
487
478
  if (!this.tx)
@@ -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
  }