@debugg-ai/debugg-ai-mcp 3.9.1 → 3.9.2

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.
@@ -18,7 +18,7 @@ import { Logger } from '../utils/logger.js';
18
18
  import { handleExternalServiceError } from '../utils/errors.js';
19
19
  import { DebuggAIServerClient } from '../services/index.js';
20
20
  import { TunnelProvisionError } from '../services/tunnels.js';
21
- import { tunnelManager } from '../services/ngrok/tunnelManager.js';
21
+ import { disposeUnhealthyTunnel } from '../utils/tunnelDisposition.js';
22
22
  import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js';
23
23
  import { extractLocalhostPort } from '../utils/urlParser.js';
24
24
  import { buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
@@ -148,18 +148,12 @@ export async function probePageHandler(input, context, rawProgressCallback) {
148
148
  elapsedMs: health.elapsedMs,
149
149
  },
150
150
  };
151
- if (tunneled.tunnelId) {
152
- const deadPort = extractLocalhostPort(tunneled.originalUrl);
153
- if (health.ngrokErrorCode && typeof deadPort === 'number') {
154
- // Tunnel-level dead (ERR_NGROK_*): evict the SHARED registry entry
155
- // so the next call re-provisions instead of re-borrowing the corpse
156
- // (bead k34o) plain stopTunnel leaves a borrowed entry to re-poison.
157
- tunnelManager.markTunnelDead(deadPort, tunneled.tunnelId).catch((err) => logger.warn(`Failed to evict dead tunnel ${tunneled.tunnelId}: ${err}`));
158
- }
159
- else {
160
- tunnelManager.stopTunnel(tunneled.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${tunneled.tunnelId}: ${err}`));
161
- }
162
- }
151
+ // Evict ONLY on a code proving the endpoint is gone; every other
152
+ // failure keeps the tunnel we are already paying for, since a
153
+ // teardown+re-provision costs two billed hours and this probe
154
+ // cannot tell a dead endpoint from a transient edge flake. See
155
+ // utils/tunnelDisposition.ts for the allowlist and the evidence.
156
+ disposeUnhealthyTunnel({ health, tunnelId: tunneled.tunnelId, originalUrl: tunneled.originalUrl });
163
157
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
164
158
  }
165
159
  }
@@ -2,7 +2,7 @@ import { Logger } from '../utils/logger.js';
2
2
  import { handleExternalServiceError } from '../utils/errors.js';
3
3
  import { DebuggAIServerClient } from '../services/index.js';
4
4
  import { TunnelProvisionError } from '../services/tunnels.js';
5
- import { tunnelManager } from '../services/ngrok/tunnelManager.js';
5
+ 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, findExistingTunnel, ensureTunnel } from '../utils/tunnelContext.js';
@@ -82,9 +82,17 @@ export async function runTestSuiteHandler(input, _context) {
82
82
  if (tunneled.targetUrl) {
83
83
  const health = await probeTunnelHealth(tunneled.targetUrl);
84
84
  if (!health.healthy) {
85
- if (tunneled.tunnelId) {
86
- tunnelManager.stopTunnel(tunneled.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${tunneled.tunnelId}: ${err}`));
87
- }
85
+ // Brought in line with the other three handlers, which this one never
86
+ // was (it evicted on EVERY failure and never got bead k34o's shared-
87
+ // registry eviction). Evict only on a code proving the endpoint is
88
+ // gone: a transient edge flake must not cost two billed hours.
89
+ // See utils/tunnelDisposition.ts.
90
+ disposeUnhealthyTunnel({ health, tunnelId: tunneled.tunnelId, originalUrl: ctx.originalUrl });
91
+ // Record the tunnel so the finally block's orphaned-key revoke can't
92
+ // fire: the tunnel we just kept is authenticated with that key, and
93
+ // revoking a live tunnel's credential would kill what we preserved.
94
+ // (On the eviction branch markTunnelDead already revokes it.)
95
+ tunnelId = tunneled.tunnelId;
88
96
  return errorResp('TunnelTrafficBlocked', `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`, { code: health.code, ngrokErrorCode: health.ngrokErrorCode, elapsedMs: health.elapsedMs });
89
97
  }
90
98
  }
@@ -13,7 +13,7 @@ import { adaptVerdict, isEnvironmentDefault } from '../services/verdictAdapter.j
13
13
  import { TunnelProvisionError } from '../services/tunnels.js';
14
14
  import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
15
15
  import { detectRepoName } from '../utils/gitContext.js';
16
- import { tunnelManager } from '../services/ngrok/tunnelManager.js';
16
+ import { disposeUnhealthyTunnel } from '../utils/tunnelDisposition.js';
17
17
  import { probeLocalPort, probeTunnelHealth, extractNgrokErrorCode } from '../utils/localReachability.js';
18
18
  import { extractLocalhostPort } from '../utils/urlParser.js';
19
19
  import { getCachedTemplateUuid, getCachedProjectUuid, invalidateTemplateCache, invalidateProjectCache, } from '../utils/handlerCaches.js';
@@ -276,21 +276,16 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
276
276
  },
277
277
  };
278
278
  logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.ngrokErrorCode ?? ''} in ${health.elapsedMs}ms`);
279
- // Tear down the broken tunnel so a subsequent call doesn't reuse it.
280
- if (ctx.tunnelId) {
281
- const deadPort = extractLocalhostPort(ctx.originalUrl);
282
- if (health.ngrokErrorCode && typeof deadPort === 'number') {
283
- // Tunnel-level dead (ERR_NGROK_*): evict the SHARED registry entry
284
- // so the next call re-provisions instead of re-borrowing the corpse.
285
- // Plain stopTunnel leaves a borrowed entry to re-poison (bead k34o).
286
- tunnelManager.markTunnelDead(deadPort, ctx.tunnelId).catch((err) => logger.warn(`Failed to evict dead tunnel ${ctx.tunnelId}: ${err}`));
287
- }
288
- else {
289
- tunnelManager.stopTunnel(ctx.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${ctx.tunnelId}: ${err}`));
290
- }
291
- }
292
- // keyId is consumed by stopTunnel's revoke path; clear so the
293
- // outer finally block doesn't double-revoke.
279
+ // Evict ONLY on a code proving the endpoint is gone; every other
280
+ // failure keeps the tunnel we are already paying for, because a
281
+ // teardown+re-provision costs two billed hours and this probe cannot
282
+ // tell a dead endpoint from a transient edge flake. See
283
+ // utils/tunnelDisposition.ts for the allowlist and the evidence.
284
+ disposeUnhealthyTunnel({ health, tunnelId: ctx.tunnelId, originalUrl: ctx.originalUrl });
285
+ // Don't revoke the key on either branch: if we evicted, markTunnelDead's
286
+ // owned path already revokes it; if we kept the tunnel, the key is that
287
+ // live tunnel's own credential and revoking it would kill what we just
288
+ // decided to preserve.
294
289
  keyId = undefined;
295
290
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
296
291
  }
@@ -15,7 +15,7 @@ import { Logger } from '../utils/logger.js';
15
15
  import { handleExternalServiceError } from '../utils/errors.js';
16
16
  import { DebuggAIServerClient } from '../services/index.js';
17
17
  import { TunnelProvisionError } from '../services/tunnels.js';
18
- import { tunnelManager } from '../services/ngrok/tunnelManager.js';
18
+ import { disposeUnhealthyTunnel } from '../utils/tunnelDisposition.js';
19
19
  import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js';
20
20
  import { extractLocalhostPort } from '../utils/urlParser.js';
21
21
  import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
@@ -138,18 +138,15 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
138
138
  },
139
139
  };
140
140
  logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.ngrokErrorCode ?? ''} in ${health.elapsedMs}ms`);
141
- if (ctx.tunnelId) {
142
- const deadPort = extractLocalhostPort(ctx.originalUrl);
143
- if (health.ngrokErrorCode && typeof deadPort === 'number') {
144
- // Tunnel-level dead (ERR_NGROK_*): evict the SHARED registry entry so
145
- // the next call re-provisions instead of re-borrowing the corpse — plain
146
- // stopTunnel leaves a borrowed entry to re-poison (bead k34o).
147
- tunnelManager.markTunnelDead(deadPort, ctx.tunnelId).catch((err) => logger.warn(`Failed to evict dead tunnel ${ctx.tunnelId}: ${err}`));
148
- }
149
- else {
150
- tunnelManager.stopTunnel(ctx.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${ctx.tunnelId}: ${err}`));
151
- }
152
- }
141
+ // Evict ONLY on a code proving the endpoint is gone; every other failure
142
+ // keeps the tunnel we are already paying for, because a teardown+
143
+ // re-provision costs two billed hours and this probe cannot tell a dead
144
+ // endpoint from a transient edge flake. See utils/tunnelDisposition.ts.
145
+ disposeUnhealthyTunnel({ health, tunnelId: ctx.tunnelId, originalUrl: ctx.originalUrl });
146
+ // Don't revoke the key on either branch: if we evicted, markTunnelDead's
147
+ // owned path already revokes it; if we kept the tunnel, the key is that
148
+ // live tunnel's own credential and revoking it would kill what we just
149
+ // decided to preserve.
153
150
  keyId = undefined;
154
151
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
155
152
  }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * What to do with a tunnel whose health probe just failed.
3
+ *
4
+ * ngrok bills a MINIMUM OF ONE HOUR PER TUNNEL, so tearing one down and standing
5
+ * a replacement up costs TWO billed hours. A teardown therefore has to be backed
6
+ * by proof that the tunnel is already gone — not by a probe result that a
7
+ * transient edge flake produces just as readily as a real death.
8
+ *
9
+ * All four tool handlers probe tunnel health before handing the URL to the remote
10
+ * browser, and all four used to gate eviction on `health.ngrokErrorCode` being
11
+ * set AT ALL, falling back to stopTunnel() otherwise. Both halves were wrong:
12
+ *
13
+ * - `ngrokErrorCode` is not a verdict. ERR_NGROK_8012 means the tunnel is ALIVE
14
+ * and its upstream refused the connection — the tunnel is literally what
15
+ * served us that error. Evicting on it orphans a live, billing tunnel and
16
+ * makes the next call provision a replacement: two billed hours spent on a
17
+ * dev server that was the actual problem.
18
+ * - the stopTunnel() fallback tore down an OWNED tunnel on ANY probe failure,
19
+ * including the transient HTTP/2 GOAWAY the ngrok edge sends to undici
20
+ * (bead k6yq — measured at roughly 1 run in 5 before the retry ladder landed,
21
+ * and still reachable whenever that ladder exhausts).
22
+ *
23
+ * Hence an explicit allowlist of codes that prove the ENDPOINT ITSELF is gone.
24
+ * Everything else leaves both the tunnel and the shared registry entry completely
25
+ * untouched and just reports TunnelTrafficBlocked: the user fixes their dev
26
+ * server and the next call reuses the same tunnel for free.
27
+ *
28
+ * Bead kmzb, confirmed live on 2026-07-27: probeTunnelHealth cannot currently
29
+ * produce ANY ngrokErrorCode against this edge. `curl` and a raw node:https
30
+ * request both get `404 + ERR_NGROK_3200` from a hostname ngrok no longer routes,
31
+ * but undici's fetch negotiates HTTP/2 and the edge answers with a GOAWAY, which
32
+ * surfaces as UND_ERR_SOCKET / NETWORK_ERROR with no code at all. So in practice
33
+ * every probe failure takes the leave-it-alone branch today. The allowlist is the
34
+ * policy that governs the moment a code CAN be produced — whether that is kmzb
35
+ * forcing HTTP/1.1 for the probe, or a caller passing the marker recorded by a
36
+ * run's own evidence (bead 4bui, the one live-confirmed source of ERR_NGROK_*).
37
+ */
38
+ import { tunnelManager } from '../services/ngrok/tunnelManager.js';
39
+ import { extractLocalhostPort } from './urlParser.js';
40
+ import { Logger } from './logger.js';
41
+ const logger = new Logger({ module: 'tunnelDisposition' });
42
+ /**
43
+ * ngrok error codes that PROVE the endpoint no longer exists, so evicting it
44
+ * costs nothing and re-borrowing it costs everyone a failed run (bead k34o).
45
+ *
46
+ * Inclusion criterion — the code must be served BY THE EDGE ABOUT A HOSTNAME IT
47
+ * NO LONGER ROUTES, i.e. it is impossible to receive it from a tunnel that is
48
+ * still up. Anything describing the upstream, the agent, or the connection is a
49
+ * live tunnel reporting on something else, and must NOT be here.
50
+ *
51
+ * Verify a candidate before adding it — the check is free and creates no tunnel:
52
+ * curl -s https://<random-uuid>.ngrok.debugg.ai/ | grep -o 'ERR_NGROK_[0-9]*'
53
+ * (use curl or node:https, NOT fetch — see the kmzb note above).
54
+ *
55
+ * ERR_NGROK_3200 — "not found". Verified live 2026-07-27: a GET to a
56
+ * nonexistent *.ngrok.debugg.ai host returns 404 with this code in the body.
57
+ * The endpoint is definitively gone; nothing is billing for it.
58
+ *
59
+ * Deliberately EXCLUDED, and the sharpest case of all:
60
+ *
61
+ * ERR_NGROK_8012 — the agent could not dial the upstream (connection refused).
62
+ * The TUNNEL IS ALIVE; it is the thing that generated the error page. Evicting
63
+ * on 8012 orphans a paid-for tunnel and buys a duplicate — two billed hours —
64
+ * to work around a dev server that is down, bound to the wrong interface, or
65
+ * still starting up. Leave it be; it will serve the very next request once the
66
+ * user's server is back.
67
+ *
68
+ * This set is pinned by a test. Adding a code has to be a deliberate act with
69
+ * evidence behind it, not a silent default to teardown.
70
+ *
71
+ * The frozen ARRAY is the source of truth, not a frozen Set: Object.freeze does
72
+ * not make a Set immutable — its entries live in internal slots, so `.add()` on
73
+ * a "frozen" Set still succeeds silently. Freezing the array is a real runtime
74
+ * guarantee; the lookup Set is derived from it and kept private.
75
+ */
76
+ const ENDPOINT_GONE_CODES = Object.freeze(['ERR_NGROK_3200']);
77
+ const ENDPOINT_GONE_LOOKUP = new Set(ENDPOINT_GONE_CODES);
78
+ /** The allowlist, as an immutable list. Read-only by construction. */
79
+ export const ENDPOINT_GONE_NGROK_CODES = ENDPOINT_GONE_CODES;
80
+ /**
81
+ * True only when the code proves the endpoint is gone. Unset / unknown codes are
82
+ * NOT proof of anything, so they answer false: the default is always to keep the
83
+ * tunnel we are already paying for.
84
+ */
85
+ export function isEndpointGone(ngrokErrorCode) {
86
+ return !!ngrokErrorCode && ENDPOINT_GONE_LOOKUP.has(ngrokErrorCode);
87
+ }
88
+ /**
89
+ * Decide — once, in one place — what an unhealthy tunnel health probe does to the
90
+ * tunnel. Called by every handler that probes, so the policy cannot drift between
91
+ * them (run_test_suite had already drifted: it evicted on every failure and never
92
+ * got bead k34o's shared-registry eviction at all).
93
+ *
94
+ * Endpoint proven gone → markTunnelDead: for an owned tunnel that disconnects and
95
+ * revokes the key; for a BORROWED one it also evicts the
96
+ * shared registry entry, which plain stopTunnel leaves
97
+ * behind for every other session to re-borrow (bead k34o).
98
+ * Anything else → nothing at all. The caller still returns
99
+ * TunnelTrafficBlocked, so the user is told; we simply do
100
+ * not spend two billed hours acting on a verdict this
101
+ * probe cannot actually deliver.
102
+ *
103
+ * Never throws and never awaits the eviction: a cleanup decision must not be able
104
+ * to fail or slow down the error response the caller is about to return.
105
+ */
106
+ export function disposeUnhealthyTunnel(args) {
107
+ const { health, tunnelId, originalUrl } = args;
108
+ if (!tunnelId)
109
+ return;
110
+ if (!isEndpointGone(health.ngrokErrorCode)) {
111
+ logger.info(`Tunnel ${tunnelId} failed its health probe (${health.code}${health.ngrokErrorCode ? ` ${health.ngrokErrorCode}` : ''}) ` +
112
+ 'but nothing proves the endpoint is gone — keeping it. Tearing down a live tunnel costs two billed hours ' +
113
+ '(1-hour minimum down, another up) and the next call reuses this one for free.');
114
+ return;
115
+ }
116
+ const port = extractLocalhostPort(originalUrl);
117
+ if (typeof port !== 'number') {
118
+ // markTunnelDead is keyed by port. Without one we cannot evict the shared
119
+ // entry safely, and a blind stopTunnel is exactly the teardown this module
120
+ // exists to prevent — so keep the tunnel and say why.
121
+ logger.warn(`Tunnel ${tunnelId} reported ${health.ngrokErrorCode} but no port could be parsed from ${originalUrl} — ` +
122
+ 'leaving it in place rather than risking a teardown of a live tunnel.');
123
+ return;
124
+ }
125
+ logger.warn(`Tunnel ${tunnelId} on port ${port} reported ${health.ngrokErrorCode} — the endpoint is gone, evicting it ` +
126
+ 'so no session re-borrows the corpse (bead k34o).');
127
+ tunnelManager.markTunnelDead(port, tunnelId).catch((err) => logger.warn(`Failed to evict dead tunnel ${tunnelId}: ${err}`));
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@debugg-ai/debugg-ai-mcp",
3
- "version": "3.9.1",
3
+ "version": "3.9.2",
4
4
  "description": "Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.",
5
5
  "type": "module",
6
6
  "bin": {