@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 {
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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 {
|
|
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
|
-
|
|
86
|
-
|
|
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 {
|
|
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
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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 {
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
+
}
|