@debugg-ai/debugg-ai-mcp 3.9.1 → 3.9.3
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/dist/handlers/probePageHandler.js +7 -13
- package/dist/handlers/runTestSuiteHandler.js +12 -4
- package/dist/handlers/testPageChangesHandler.js +11 -16
- package/dist/handlers/triggerCrawlHandler.js +10 -13
- package/dist/services/ngrok/ngrokAgentInspector.js +210 -0
- package/dist/services/ngrok/tunnelFaultInjection.js +15 -2
- package/dist/services/ngrok/tunnelManager.js +250 -39
- package/dist/services/ngrok/tunnelRegistry.js +174 -15
- package/dist/utils/localReachability.js +148 -6
- package/dist/utils/tunnelDisposition.js +128 -0
- package/package.json +1 -1
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
* misconfigured ngrok, etc.)
|
|
15
15
|
*/
|
|
16
16
|
import { createConnection } from 'node:net';
|
|
17
|
+
import { request as httpRequest } from 'node:http';
|
|
18
|
+
import { request as httpsRequest } from 'node:https';
|
|
19
|
+
import { Readable } from 'node:stream';
|
|
17
20
|
export async function probeLocalPort(port, opts = {}) {
|
|
18
21
|
const host = opts.host ?? '127.0.0.1';
|
|
19
22
|
const timeoutMs = opts.timeoutMs ?? 1500;
|
|
@@ -72,9 +75,134 @@ const TRANSIENT_CAUSE_CODES = new Set([
|
|
|
72
75
|
'EAI_AGAIN', // DNS: temporary resolver failure
|
|
73
76
|
'ECONNRESET', // edge dropped the connection mid-handshake
|
|
74
77
|
'EPIPE',
|
|
75
|
-
|
|
78
|
+
// undici codes. Since bead kmzb the default probe transport is node:https,
|
|
79
|
+
// which reports the OS codes above instead — these stay for an injected
|
|
80
|
+
// fetchFn, and as the record of what the HTTP/2 path used to produce.
|
|
81
|
+
'UND_ERR_SOCKET',
|
|
76
82
|
'UND_ERR_CONNECT_TIMEOUT',
|
|
77
83
|
]);
|
|
84
|
+
/**
|
|
85
|
+
* ngrok codes meaning "the edge does not route this hostname".
|
|
86
|
+
*
|
|
87
|
+
* Kept here deliberately separate from tunnelDisposition's ENDPOINT_GONE
|
|
88
|
+
* allowlist even though they currently hold the same code: that list decides
|
|
89
|
+
* whether a verdict may DESTROY a tunnel, this one decides whether a verdict is
|
|
90
|
+
* worth double-checking first. A code could reasonably be on one and not the
|
|
91
|
+
* other, and coupling them would make this module depend on policy it has no
|
|
92
|
+
* business knowing.
|
|
93
|
+
*/
|
|
94
|
+
const ENDPOINT_NOT_FOUND_CODES = new Set(['ERR_NGROK_3200']);
|
|
95
|
+
// ─ HTTP/1.1 probe transport (bead kmzb) ──────────────────────────────────────
|
|
96
|
+
/**
|
|
97
|
+
* `fetch`-shaped GET that speaks HTTP/1.1, because the global fetch cannot.
|
|
98
|
+
*
|
|
99
|
+
* Measured against the ngrok edge on 2026-07-27, Node v26:
|
|
100
|
+
*
|
|
101
|
+
* ALPN offer [h2, http/1.1] → edge selects h2
|
|
102
|
+
* raw HTTP/2 GET of a hostname ngrok no longer routes
|
|
103
|
+
* → 404 with ERR_NGROK_3200 in the body, AND a GOAWAY (code 0, NO_ERROR)
|
|
104
|
+
* on the same connection
|
|
105
|
+
* undici (global fetch) over that h2 connection
|
|
106
|
+
* → TypeError: fetch failed / UND_ERR_SOCKET, 3/3 — the response is lost
|
|
107
|
+
* to the concurrent GOAWAY and never surfaces
|
|
108
|
+
* node:https (HTTP/1.1)
|
|
109
|
+
* → 404 + ERR_NGROK_3200, 3/3
|
|
110
|
+
*
|
|
111
|
+
* So the edge was answering us the whole time and undici was dropping the
|
|
112
|
+
* answer. That is why `ngrokErrorCode` had never once been populated in
|
|
113
|
+
* production, which in turn made tunnelDisposition's ENDPOINT_GONE allowlist
|
|
114
|
+
* unreachable: every probe failure looked like a transient network error, and
|
|
115
|
+
* a genuinely dead endpoint could never be told apart from a flake. Speaking
|
|
116
|
+
* HTTP/1.1 makes the distinction observable again and re-activates that
|
|
117
|
+
* allowlist — so ERR_NGROK_3200 can once more evict a tunnel that is provably
|
|
118
|
+
* gone, while everything short of proof still keeps the tunnel we are paying
|
|
119
|
+
* for. It also removes the h2 GOAWAY that bead k6yq's retry ladder existed to
|
|
120
|
+
* paper over, leaving the ladder as a genuine safety net rather than a
|
|
121
|
+
* load-bearing workaround.
|
|
122
|
+
*
|
|
123
|
+
* Node's fetch offers no way to disable h2, hence a transport rather than an
|
|
124
|
+
* option. `http.ClientRequest` — which `https.request` returns — implements
|
|
125
|
+
* HTTP/1.x only, so this cannot regress into h2 no matter what a future Node
|
|
126
|
+
* negotiates by default. The seam is deliberately `typeof fetch` so every
|
|
127
|
+
* caller and test that injects `fetchFn` is unaffected.
|
|
128
|
+
*/
|
|
129
|
+
export const http1Fetch = async (input, init) => {
|
|
130
|
+
const url = new URL(typeof input === 'string' ? input : input.url ?? String(input));
|
|
131
|
+
const isPlainHttp = url.protocol === 'http:';
|
|
132
|
+
const signal = init?.signal ?? undefined;
|
|
133
|
+
const options = {
|
|
134
|
+
protocol: url.protocol,
|
|
135
|
+
host: url.hostname,
|
|
136
|
+
port: url.port || (isPlainHttp ? 80 : 443),
|
|
137
|
+
path: `${url.pathname}${url.search}`,
|
|
138
|
+
method: (init?.method ?? 'GET').toUpperCase(),
|
|
139
|
+
headers: init?.headers ?? {},
|
|
140
|
+
// One-shot probe — do not leave a pooled keep-alive socket behind.
|
|
141
|
+
agent: false,
|
|
142
|
+
};
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
let settled = false;
|
|
145
|
+
const fail = (err) => {
|
|
146
|
+
if (settled)
|
|
147
|
+
return;
|
|
148
|
+
settled = true;
|
|
149
|
+
reject(err);
|
|
150
|
+
};
|
|
151
|
+
const onResponse = (res) => {
|
|
152
|
+
if (settled)
|
|
153
|
+
return;
|
|
154
|
+
settled = true;
|
|
155
|
+
const status = res.statusCode ?? 0;
|
|
156
|
+
// Response rejects a body on these statuses; they have none anyway.
|
|
157
|
+
const bodyless = status === 101 || status === 204 || status === 205 || status === 304;
|
|
158
|
+
if (bodyless)
|
|
159
|
+
res.resume();
|
|
160
|
+
resolve(new Response(bodyless ? null : Readable.toWeb(res), {
|
|
161
|
+
status,
|
|
162
|
+
headers: headersFrom(res.headers),
|
|
163
|
+
}));
|
|
164
|
+
};
|
|
165
|
+
const req = isPlainHttp
|
|
166
|
+
? httpRequest(options, onResponse)
|
|
167
|
+
: httpsRequest(options, onResponse);
|
|
168
|
+
if (signal) {
|
|
169
|
+
if (signal.aborted) {
|
|
170
|
+
req.destroy();
|
|
171
|
+
return fail(abortError());
|
|
172
|
+
}
|
|
173
|
+
signal.addEventListener('abort', () => {
|
|
174
|
+
req.destroy();
|
|
175
|
+
fail(abortError());
|
|
176
|
+
}, { once: true });
|
|
177
|
+
}
|
|
178
|
+
req.on('error', fail);
|
|
179
|
+
req.end();
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
/** Matches what an aborted fetch throws, so probeOnce's TIMEOUT arm still fires. */
|
|
183
|
+
function abortError() {
|
|
184
|
+
const err = new Error('The operation was aborted.');
|
|
185
|
+
err.name = 'AbortError';
|
|
186
|
+
return err;
|
|
187
|
+
}
|
|
188
|
+
/** node:http header bag → Headers, skipping the pseudo/array oddities. */
|
|
189
|
+
function headersFrom(raw) {
|
|
190
|
+
const headers = new Headers();
|
|
191
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
192
|
+
if (value === undefined)
|
|
193
|
+
continue;
|
|
194
|
+
for (const v of Array.isArray(value) ? value : [value]) {
|
|
195
|
+
try {
|
|
196
|
+
headers.append(key, v);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// A header Headers refuses (invalid name from a hostile server) is not
|
|
200
|
+
// worth failing a health probe over.
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return headers;
|
|
205
|
+
}
|
|
78
206
|
/** undici hides the real error behind `TypeError: fetch failed` — dig it out. */
|
|
79
207
|
function errorCodeOf(err) {
|
|
80
208
|
return err?.cause?.code ?? err?.code;
|
|
@@ -113,7 +241,8 @@ export async function probeTunnelHealth(tunnelUrl, opts = {}) {
|
|
|
113
241
|
}
|
|
114
242
|
async function probeOnce(tunnelUrl, opts, started) {
|
|
115
243
|
const timeoutMs = opts.timeoutMs ?? 5000;
|
|
116
|
-
|
|
244
|
+
// Bead kmzb: HTTP/1.1, not the global fetch — see http1Fetch.
|
|
245
|
+
const fetchImpl = opts.fetchFn ?? http1Fetch;
|
|
117
246
|
const controller = new AbortController();
|
|
118
247
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
119
248
|
try {
|
|
@@ -129,18 +258,31 @@ async function probeOnce(tunnelUrl, opts, started) {
|
|
|
129
258
|
// ngrok error pages are small; a full user app body is a waste.
|
|
130
259
|
const bodyText = await readCapped(res, 4096);
|
|
131
260
|
const ngrokErr = extractNgrokErrorCode(bodyText);
|
|
132
|
-
//
|
|
133
|
-
//
|
|
261
|
+
// An ngrok error page. A RESPONSE is proof the edge is up, so these are
|
|
262
|
+
// returned as-is rather than retried into a false pass, and bead 4bui's
|
|
134
263
|
// marker-driven reclassification keeps seeing the real verdict.
|
|
264
|
+
//
|
|
265
|
+
// One exception, and it exists only because bead kmzb made these codes
|
|
266
|
+
// reachable at all: ENDPOINT_NOT_FOUND is the single verdict that
|
|
267
|
+
// AUTHORISES A TEARDOWN (tunnelDisposition's allowlist), and the ngrok
|
|
268
|
+
// agent passes through a short window where it is briefly true — while a
|
|
269
|
+
// dropped session reconnects and re-announces the same hostname. Acting on
|
|
270
|
+
// one sample would kill a tunnel that was about to come back, at a cost of
|
|
271
|
+
// two billed hours. So this code alone is confirmed across the retry
|
|
272
|
+
// ladder: a reconnect resolves inside it, a genuinely deleted endpoint
|
|
273
|
+
// answers the same way every time and is still reported as gone.
|
|
135
274
|
if (ngrokErr) {
|
|
275
|
+
const notFound = ENDPOINT_NOT_FOUND_CODES.has(ngrokErr);
|
|
136
276
|
return {
|
|
137
|
-
retryable:
|
|
277
|
+
retryable: notFound,
|
|
138
278
|
result: {
|
|
139
279
|
healthy: false,
|
|
140
280
|
status: res.status,
|
|
141
281
|
code: 'NGROK_ERROR',
|
|
142
282
|
ngrokErrorCode: ngrokErr,
|
|
143
|
-
detail:
|
|
283
|
+
detail: notFound
|
|
284
|
+
? `ngrok returned ${ngrokErr} — the edge no longer routes this tunnel hostname`
|
|
285
|
+
: `ngrok returned ${ngrokErr} — tunnel established but traffic could not reach dev server`,
|
|
144
286
|
elapsedMs: Date.now() - started,
|
|
145
287
|
},
|
|
146
288
|
};
|
|
@@ -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
|
+
}
|