@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.
- package/CHANGELOG.md +61 -1
- package/README.md +32 -14
- package/dist/handlers/createEnvironmentHandler.js +4 -4
- package/dist/handlers/createProjectHandler.js +1 -1
- package/dist/handlers/environmentSessionsHandler.js +2 -18
- package/dist/handlers/probePageHandler.js +10 -13
- package/dist/handlers/runTestSuiteHandler.js +8 -9
- package/dist/handlers/searchEnvironmentsHandler.js +3 -6
- package/dist/handlers/searchExecutionsHandler.js +3 -11
- package/dist/handlers/searchProjectsHandler.js +1 -6
- package/dist/handlers/testPageChangesHandler.js +88 -183
- package/dist/handlers/triggerCrawlHandler.js +52 -44
- package/dist/handlers/updateEnvironmentHandler.js +2 -2
- package/dist/index.js +1 -1
- package/dist/services/caddy/caddyProxy.js +7 -6
- package/dist/services/index.js +18 -21
- package/dist/services/tunnel/debuggTransport.js +9 -9
- package/dist/services/tunnel/protocol/constants.js +3 -3
- package/dist/services/tunnel/transport.js +28 -9
- package/dist/services/{ngrok → tunnel}/tunnelFaultInjection.js +9 -15
- package/dist/services/{ngrok → tunnel}/tunnelManager.js +65 -131
- package/dist/services/{ngrok → tunnel}/tunnelRegistry.js +2 -2
- package/dist/services/tunnels.js +64 -46
- package/dist/services/verdictAdapter.js +167 -41
- package/dist/services/workflows.js +10 -1
- package/dist/tools/testPageChanges.js +1 -1
- package/dist/utils/authorizedCredentialHosts.js +8 -20
- package/dist/utils/confirmDestructive.js +1 -2
- package/dist/utils/errors.js +4 -5
- package/dist/utils/imageUtils.js +2 -2
- package/dist/utils/localReachability.js +62 -341
- package/dist/utils/resolveProject.js +1 -1
- package/dist/utils/telemetry.js +4 -4
- package/dist/utils/tunnelContext.js +11 -9
- package/dist/utils/tunnelDisposition.js +51 -69
- package/dist/utils/tunnelDomains.js +30 -21
- package/dist/utils/urlParser.js +38 -6
- package/package.json +2 -3
- package/dist/services/ngrok/error.js +0 -3
- package/dist/services/ngrok/ngrokAgentInspector.js +0 -210
- package/dist/services/ngrok/ngrokAgentSession.js +0 -49
- package/dist/services/ngrok/types.js +0 -1
- package/dist/services/tunnel/ngrokTransport.js +0 -165
|
@@ -3,21 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* MCP owns the tunnel lifecycle. It must validate that the user's claimed
|
|
5
5
|
* localhost URL is actually reachable BEFORE calling the backend provision
|
|
6
|
-
* API and BEFORE committing to the slow
|
|
7
|
-
* these probes, unreachable apps result in a 5-minute false-positive pass
|
|
8
|
-
*
|
|
6
|
+
* API and BEFORE committing to the slow tunnel/browser-agent path. Without
|
|
7
|
+
* these probes, unreachable apps result in a 5-minute false-positive pass as
|
|
8
|
+
* the browser agent burns its step budget on our upstream-refused error page.
|
|
9
9
|
*
|
|
10
10
|
* Two probes:
|
|
11
11
|
* - probeLocalPort(port): pre-flight TCP connect to 127.0.0.1:<port>
|
|
12
|
-
* - probeTunnelHealth(url):
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* - probeTunnelHealth(url): asks the tunnel server to fetch the tunnel's own
|
|
13
|
+
* public URL and report what it got — proof that traffic actually flows
|
|
14
|
+
* end to end (catches IPv4/IPv6 bind mismatches, a dev server that died
|
|
15
|
+
* after the port probe, a tunnel that never came up).
|
|
15
16
|
*/
|
|
16
17
|
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';
|
|
20
|
-
import { tunnelDomainFor } from './tunnelDomains.js';
|
|
21
18
|
import { getControlProbe } from '../services/tunnel/probeRegistry.js';
|
|
22
19
|
export async function probeLocalPort(port, opts = {}) {
|
|
23
20
|
const host = opts.host ?? '127.0.0.1';
|
|
@@ -58,187 +55,54 @@ export async function probeLocalPort(port, opts = {}) {
|
|
|
58
55
|
});
|
|
59
56
|
}
|
|
60
57
|
/**
|
|
61
|
-
*
|
|
62
|
-
* NOT "the tunnel is broken".
|
|
58
|
+
* Markers meaning "the edge does not route this hostname".
|
|
63
59
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* dev server as unreachable roughly 1 run in 5-14.
|
|
70
|
-
*
|
|
71
|
-
* Deliberately EXCLUDED (these resolve and answer — they are real faults):
|
|
72
|
-
* ECONNREFUSED / EHOSTUNREACH / ENETUNREACH, TLS errors, any HTTP response
|
|
73
|
-
* (incl. 502 + ERR_NGROK_*), and timeouts.
|
|
74
|
-
*/
|
|
75
|
-
const TRANSIENT_CAUSE_CODES = new Set([
|
|
76
|
-
'ENOTFOUND', // DNS: name not resolvable yet
|
|
77
|
-
'EAI_AGAIN', // DNS: temporary resolver failure
|
|
78
|
-
'ECONNRESET', // edge dropped the connection mid-handshake
|
|
79
|
-
'EPIPE',
|
|
80
|
-
// undici codes. Since bead kmzb the default probe transport is node:https,
|
|
81
|
-
// which reports the OS codes above instead — these stay for an injected
|
|
82
|
-
// fetchFn, and as the record of what the HTTP/2 path used to produce.
|
|
83
|
-
'UND_ERR_SOCKET',
|
|
84
|
-
'UND_ERR_CONNECT_TIMEOUT',
|
|
85
|
-
]);
|
|
86
|
-
/**
|
|
87
|
-
* ngrok codes meaning "the edge does not route this hostname".
|
|
88
|
-
*
|
|
89
|
-
* Kept here deliberately separate from tunnelDisposition's ENDPOINT_GONE
|
|
90
|
-
* allowlist even though they currently hold the same code: that list decides
|
|
91
|
-
* whether a verdict may DESTROY a tunnel, this one decides whether a verdict is
|
|
92
|
-
* worth double-checking first. A code could reasonably be on one and not the
|
|
93
|
-
* other, and coupling them would make this module depend on policy it has no
|
|
94
|
-
* business knowing.
|
|
60
|
+
* Kept deliberately separate from tunnelDisposition's ENDPOINT_GONE allowlist
|
|
61
|
+
* even though the codes overlap: that list decides whether a verdict may
|
|
62
|
+
* DESTROY a tunnel, this one decides whether a verdict is worth double-checking
|
|
63
|
+
* first. A code could reasonably be on one and not the other, and coupling them
|
|
64
|
+
* would make this module depend on policy it has no business knowing.
|
|
95
65
|
*/
|
|
96
66
|
const ENDPOINT_NOT_FOUND_CODES = new Set([
|
|
97
|
-
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
67
|
+
// The server already holds a browser request for RECONNECT_GRACE_MS (5s)
|
|
68
|
+
// while a client reconnects before rendering this, but a reconnect that
|
|
69
|
+
// outlasts the grace window would otherwise evict a tunnel that is about to
|
|
70
|
+
// come back, so confirm it across the ladder too.
|
|
71
|
+
//
|
|
102
72
|
// DEBUGG_TUNNEL_UNKNOWN is deliberately NOT here: the id is gone, and
|
|
103
73
|
// re-probing cannot change that answer.
|
|
104
74
|
'DEBUGG_TUNNEL_OFFLINE',
|
|
105
75
|
]);
|
|
106
|
-
// ─ HTTP/1.1 probe transport (bead kmzb) ──────────────────────────────────────
|
|
107
|
-
/**
|
|
108
|
-
* `fetch`-shaped GET that speaks HTTP/1.1, because the global fetch cannot.
|
|
109
|
-
*
|
|
110
|
-
* Measured against the ngrok edge on 2026-07-27, Node v26:
|
|
111
|
-
*
|
|
112
|
-
* ALPN offer [h2, http/1.1] → edge selects h2
|
|
113
|
-
* raw HTTP/2 GET of a hostname ngrok no longer routes
|
|
114
|
-
* → 404 with ERR_NGROK_3200 in the body, AND a GOAWAY (code 0, NO_ERROR)
|
|
115
|
-
* on the same connection
|
|
116
|
-
* undici (global fetch) over that h2 connection
|
|
117
|
-
* → TypeError: fetch failed / UND_ERR_SOCKET, 3/3 — the response is lost
|
|
118
|
-
* to the concurrent GOAWAY and never surfaces
|
|
119
|
-
* node:https (HTTP/1.1)
|
|
120
|
-
* → 404 + ERR_NGROK_3200, 3/3
|
|
121
|
-
*
|
|
122
|
-
* So the edge was answering us the whole time and undici was dropping the
|
|
123
|
-
* answer. That is why `ngrokErrorCode` had never once been populated in
|
|
124
|
-
* production, which in turn made tunnelDisposition's ENDPOINT_GONE allowlist
|
|
125
|
-
* unreachable: every probe failure looked like a transient network error, and
|
|
126
|
-
* a genuinely dead endpoint could never be told apart from a flake. Speaking
|
|
127
|
-
* HTTP/1.1 makes the distinction observable again and re-activates that
|
|
128
|
-
* allowlist — so ERR_NGROK_3200 can once more evict a tunnel that is provably
|
|
129
|
-
* gone, while everything short of proof still keeps the tunnel we are paying
|
|
130
|
-
* for. It also removes the h2 GOAWAY that bead k6yq's retry ladder existed to
|
|
131
|
-
* paper over, leaving the ladder as a genuine safety net rather than a
|
|
132
|
-
* load-bearing workaround.
|
|
133
|
-
*
|
|
134
|
-
* Node's fetch offers no way to disable h2, hence a transport rather than an
|
|
135
|
-
* option. `http.ClientRequest` — which `https.request` returns — implements
|
|
136
|
-
* HTTP/1.x only, so this cannot regress into h2 no matter what a future Node
|
|
137
|
-
* negotiates by default. The seam is deliberately `typeof fetch` so every
|
|
138
|
-
* caller and test that injects `fetchFn` is unaffected.
|
|
139
|
-
*/
|
|
140
|
-
export const http1Fetch = async (input, init) => {
|
|
141
|
-
const url = new URL(typeof input === 'string' ? input : input.url ?? String(input));
|
|
142
|
-
const isPlainHttp = url.protocol === 'http:';
|
|
143
|
-
const signal = init?.signal ?? undefined;
|
|
144
|
-
const options = {
|
|
145
|
-
protocol: url.protocol,
|
|
146
|
-
host: url.hostname,
|
|
147
|
-
port: url.port || (isPlainHttp ? 80 : 443),
|
|
148
|
-
path: `${url.pathname}${url.search}`,
|
|
149
|
-
method: (init?.method ?? 'GET').toUpperCase(),
|
|
150
|
-
headers: init?.headers ?? {},
|
|
151
|
-
// One-shot probe — do not leave a pooled keep-alive socket behind.
|
|
152
|
-
agent: false,
|
|
153
|
-
};
|
|
154
|
-
return new Promise((resolve, reject) => {
|
|
155
|
-
let settled = false;
|
|
156
|
-
const fail = (err) => {
|
|
157
|
-
if (settled)
|
|
158
|
-
return;
|
|
159
|
-
settled = true;
|
|
160
|
-
reject(err);
|
|
161
|
-
};
|
|
162
|
-
const onResponse = (res) => {
|
|
163
|
-
if (settled)
|
|
164
|
-
return;
|
|
165
|
-
settled = true;
|
|
166
|
-
const status = res.statusCode ?? 0;
|
|
167
|
-
// Response rejects a body on these statuses; they have none anyway.
|
|
168
|
-
const bodyless = status === 101 || status === 204 || status === 205 || status === 304;
|
|
169
|
-
if (bodyless)
|
|
170
|
-
res.resume();
|
|
171
|
-
resolve(new Response(bodyless ? null : Readable.toWeb(res), {
|
|
172
|
-
status,
|
|
173
|
-
headers: headersFrom(res.headers),
|
|
174
|
-
}));
|
|
175
|
-
};
|
|
176
|
-
const req = isPlainHttp
|
|
177
|
-
? httpRequest(options, onResponse)
|
|
178
|
-
: httpsRequest(options, onResponse);
|
|
179
|
-
if (signal) {
|
|
180
|
-
if (signal.aborted) {
|
|
181
|
-
req.destroy();
|
|
182
|
-
return fail(abortError());
|
|
183
|
-
}
|
|
184
|
-
signal.addEventListener('abort', () => {
|
|
185
|
-
req.destroy();
|
|
186
|
-
fail(abortError());
|
|
187
|
-
}, { once: true });
|
|
188
|
-
}
|
|
189
|
-
req.on('error', fail);
|
|
190
|
-
req.end();
|
|
191
|
-
});
|
|
192
|
-
};
|
|
193
|
-
/** Matches what an aborted fetch throws, so probeOnce's TIMEOUT arm still fires. */
|
|
194
|
-
function abortError() {
|
|
195
|
-
const err = new Error('The operation was aborted.');
|
|
196
|
-
err.name = 'AbortError';
|
|
197
|
-
return err;
|
|
198
|
-
}
|
|
199
|
-
/** node:http header bag → Headers, skipping the pseudo/array oddities. */
|
|
200
|
-
function headersFrom(raw) {
|
|
201
|
-
const headers = new Headers();
|
|
202
|
-
for (const [key, value] of Object.entries(raw)) {
|
|
203
|
-
if (value === undefined)
|
|
204
|
-
continue;
|
|
205
|
-
for (const v of Array.isArray(value) ? value : [value]) {
|
|
206
|
-
try {
|
|
207
|
-
headers.append(key, v);
|
|
208
|
-
}
|
|
209
|
-
catch {
|
|
210
|
-
// A header Headers refuses (invalid name from a hostile server) is not
|
|
211
|
-
// worth failing a health probe over.
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
return headers;
|
|
216
|
-
}
|
|
217
|
-
/** undici hides the real error behind `TypeError: fetch failed` — dig it out. */
|
|
218
|
-
function errorCodeOf(err) {
|
|
219
|
-
return err?.cause?.code ?? err?.code;
|
|
220
|
-
}
|
|
221
|
-
function isTransientConnectionError(err) {
|
|
222
|
-
const code = errorCodeOf(err);
|
|
223
|
-
return code !== undefined && TRANSIENT_CAUSE_CODES.has(code);
|
|
224
|
-
}
|
|
225
76
|
/**
|
|
226
77
|
* Probe that traffic actually flows through the tunnel.
|
|
227
78
|
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
79
|
+
* ONE transport, ONE mechanism: a tunnel hostname resolves only inside our
|
|
80
|
+
* VPC, so it cannot be fetched from the user's machine at all. The health
|
|
81
|
+
* question goes over the tunnel's own control websocket (PROBE /
|
|
82
|
+
* PROBE_RESULT), and the server answers it by making a REAL request back
|
|
83
|
+
* through the public ingress path — same ALB, same nginx, same routing a
|
|
84
|
+
* browser takes. That is strictly better evidence than the client-side HTTP
|
|
85
|
+
* GET this used to do while tunnel URLs were public ngrok hostnames, which is
|
|
86
|
+
* why the HTTP transport (and its `fetchFn` seam, its HTTP/1.1-forcing
|
|
87
|
+
* `http1Fetch`, and the undici-flake retry codes that went with it) went away
|
|
88
|
+
* with ngrok rather than being kept as a fallback: there is nothing left it
|
|
89
|
+
* could reach.
|
|
90
|
+
*
|
|
91
|
+
* Bead k6yq: a freshly created tunnel can bounce the first probe while being
|
|
92
|
+
* perfectly healthy (a session mid-reconnect), so connection-level failures
|
|
93
|
+
* are retried with a short backoff. Every other outcome — any status the app
|
|
94
|
+
* answered with, a timeout, an unknown id — is returned on the first attempt,
|
|
95
|
+
* so a real fault is never retried into a false pass.
|
|
233
96
|
*/
|
|
234
97
|
export async function probeTunnelHealth(tunnelUrl, opts = {}) {
|
|
235
98
|
const maxAttempts = Math.max(1, opts.maxAttempts ?? 3);
|
|
236
99
|
const backoff = opts.retryBackoffMs ?? [150, 350];
|
|
237
100
|
const sleep = opts.sleepFn ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
101
|
+
const timeoutMs = opts.timeoutMs ?? 5000;
|
|
238
102
|
const started = Date.now();
|
|
239
103
|
let last;
|
|
240
104
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
241
|
-
const { result, retryable } = await
|
|
105
|
+
const { result, retryable } = await probeOverControlChannel(tunnelUrl, timeoutMs, started);
|
|
242
106
|
if (!retryable)
|
|
243
107
|
return result;
|
|
244
108
|
last = result;
|
|
@@ -246,132 +110,15 @@ export async function probeTunnelHealth(tunnelUrl, opts = {}) {
|
|
|
246
110
|
await sleep(backoff[attempt - 1] ?? backoff[backoff.length - 1] ?? 150);
|
|
247
111
|
}
|
|
248
112
|
}
|
|
249
|
-
// Ladder exhausted on a transient error — the
|
|
250
|
-
// "not ready" that never becomes ready IS unhealthy.
|
|
113
|
+
// Ladder exhausted on a transient error — the tunnel never came up. Report
|
|
114
|
+
// it: "not ready" that never becomes ready IS unhealthy.
|
|
251
115
|
return last;
|
|
252
116
|
}
|
|
253
|
-
|
|
254
|
-
const timeoutMs = opts.timeoutMs ?? 5000;
|
|
255
|
-
// A debugg tunnel hostname resolves only inside our VPC, so it cannot be
|
|
256
|
-
// fetched from the user's machine at all. Its health question goes over the
|
|
257
|
-
// tunnel's own control websocket instead, and the server answers it by
|
|
258
|
-
// making a REAL request through the public ingress path — same ALB, same
|
|
259
|
-
// nginx, same routing a browser takes. Everything below this branch (the
|
|
260
|
-
// retry ladder, the confirm-across-retries rule, the result shape the
|
|
261
|
-
// handlers render) is shared by both transports.
|
|
262
|
-
if (tunnelDomainFor(tunnelUrl)?.transport === 'debugg') {
|
|
263
|
-
return probeOverControlChannel(tunnelUrl, timeoutMs, started);
|
|
264
|
-
}
|
|
265
|
-
// Bead kmzb: HTTP/1.1, not the global fetch — see http1Fetch.
|
|
266
|
-
const fetchImpl = opts.fetchFn ?? http1Fetch;
|
|
267
|
-
const controller = new AbortController();
|
|
268
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
269
|
-
try {
|
|
270
|
-
const res = await fetchImpl(tunnelUrl, {
|
|
271
|
-
method: 'GET',
|
|
272
|
-
redirect: 'manual',
|
|
273
|
-
signal: controller.signal,
|
|
274
|
-
// Many user apps reject HEAD; stick to GET for broader compatibility.
|
|
275
|
-
headers: { 'User-Agent': 'debugg-ai-mcp/tunnel-health-probe' },
|
|
276
|
-
});
|
|
277
|
-
clearTimeout(timer);
|
|
278
|
-
// Read body so we can inspect for ngrok error markers. Cap at 4KB —
|
|
279
|
-
// ngrok error pages are small; a full user app body is a waste.
|
|
280
|
-
const bodyText = await readCapped(res, 4096);
|
|
281
|
-
const ngrokErr = extractNgrokErrorCode(bodyText);
|
|
282
|
-
// An ngrok error page. A RESPONSE is proof the edge is up, so these are
|
|
283
|
-
// returned as-is rather than retried into a false pass, and bead 4bui's
|
|
284
|
-
// marker-driven reclassification keeps seeing the real verdict.
|
|
285
|
-
//
|
|
286
|
-
// One exception, and it exists only because bead kmzb made these codes
|
|
287
|
-
// reachable at all: ENDPOINT_NOT_FOUND is the single verdict that
|
|
288
|
-
// AUTHORISES A TEARDOWN (tunnelDisposition's allowlist), and the ngrok
|
|
289
|
-
// agent passes through a short window where it is briefly true — while a
|
|
290
|
-
// dropped session reconnects and re-announces the same hostname. Acting on
|
|
291
|
-
// one sample would kill a tunnel that was about to come back, at a cost of
|
|
292
|
-
// two billed hours. So this code alone is confirmed across the retry
|
|
293
|
-
// ladder: a reconnect resolves inside it, a genuinely deleted endpoint
|
|
294
|
-
// answers the same way every time and is still reported as gone.
|
|
295
|
-
if (ngrokErr) {
|
|
296
|
-
const notFound = ENDPOINT_NOT_FOUND_CODES.has(ngrokErr);
|
|
297
|
-
return {
|
|
298
|
-
retryable: notFound,
|
|
299
|
-
result: {
|
|
300
|
-
healthy: false,
|
|
301
|
-
status: res.status,
|
|
302
|
-
code: 'NGROK_ERROR',
|
|
303
|
-
ngrokErrorCode: ngrokErr,
|
|
304
|
-
detail: notFound
|
|
305
|
-
? `ngrok returned ${ngrokErr} — the edge no longer routes this tunnel hostname`
|
|
306
|
-
: `ngrok returned ${ngrokErr} — tunnel established but traffic could not reach dev server`,
|
|
307
|
-
elapsedMs: Date.now() - started,
|
|
308
|
-
},
|
|
309
|
-
};
|
|
310
|
-
}
|
|
311
|
-
if (res.status === 502 || res.status === 504) {
|
|
312
|
-
return {
|
|
313
|
-
retryable: false,
|
|
314
|
-
result: {
|
|
315
|
-
healthy: false,
|
|
316
|
-
status: res.status,
|
|
317
|
-
code: 'BAD_GATEWAY',
|
|
318
|
-
detail: `tunnel returned ${res.status} without an ngrok error marker — gateway is rejecting upstream`,
|
|
319
|
-
elapsedMs: Date.now() - started,
|
|
320
|
-
},
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
// Any other response (incl. 4xx from user's app) means traffic reached
|
|
324
|
-
// the dev server — that's healthy from the TUNNEL's perspective. The
|
|
325
|
-
// user's 404 is a user concern, not a tunnel concern.
|
|
326
|
-
return {
|
|
327
|
-
retryable: false,
|
|
328
|
-
result: {
|
|
329
|
-
healthy: true,
|
|
330
|
-
status: res.status,
|
|
331
|
-
elapsedMs: Date.now() - started,
|
|
332
|
-
},
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
catch (err) {
|
|
336
|
-
clearTimeout(timer);
|
|
337
|
-
const e = err;
|
|
338
|
-
if (e?.name === 'AbortError' || /abort|timeout/i.test(e?.message ?? '')) {
|
|
339
|
-
// Not retried: a hanging tunnel must not cost 3x the timeout budget.
|
|
340
|
-
return {
|
|
341
|
-
retryable: false,
|
|
342
|
-
result: {
|
|
343
|
-
healthy: false,
|
|
344
|
-
code: 'TIMEOUT',
|
|
345
|
-
detail: `tunnel health probe timed out after ${timeoutMs}ms`,
|
|
346
|
-
elapsedMs: Date.now() - started,
|
|
347
|
-
},
|
|
348
|
-
};
|
|
349
|
-
}
|
|
350
|
-
// Surface the cause code: undici reports every network failure as the
|
|
351
|
-
// undiagnosable "fetch failed" and hides the truth in err.cause.
|
|
352
|
-
const causeCode = errorCodeOf(e);
|
|
353
|
-
const baseMsg = e?.message ?? String(err);
|
|
354
|
-
const causeMsg = e?.cause?.message;
|
|
355
|
-
const detail = causeCode
|
|
356
|
-
? `${baseMsg} (${causeCode}${causeMsg && causeMsg !== baseMsg ? `: ${causeMsg}` : ''})`
|
|
357
|
-
: baseMsg;
|
|
358
|
-
return {
|
|
359
|
-
retryable: isTransientConnectionError(e),
|
|
360
|
-
result: {
|
|
361
|
-
healthy: false,
|
|
362
|
-
code: 'NETWORK_ERROR',
|
|
363
|
-
detail,
|
|
364
|
-
elapsedMs: Date.now() - started,
|
|
365
|
-
},
|
|
366
|
-
};
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
// ─ Control-channel probe (debugg transport) ──────────────────────────────────
|
|
117
|
+
// ─ Control-channel probe ─────────────────────────────────────────────────────
|
|
370
118
|
/**
|
|
371
119
|
* Ask the tunnel server to fetch `path` back through this tunnel and report
|
|
372
|
-
* what it got.
|
|
373
|
-
*
|
|
374
|
-
* and must not be re-derived here.
|
|
120
|
+
* what it got. The PROBE_RESULT → TunnelHealthProbeResult mapping is pinned on
|
|
121
|
+
* bead debugg_ai_mcp-xkoh.1.2 §9 and must not be re-derived here.
|
|
375
122
|
*/
|
|
376
123
|
async function probeOverControlChannel(tunnelUrl, timeoutMs, started) {
|
|
377
124
|
const elapsed = () => Date.now() - started;
|
|
@@ -384,8 +131,8 @@ async function probeOverControlChannel(tunnelUrl, timeoutMs, started) {
|
|
|
384
131
|
retryable: false,
|
|
385
132
|
result: {
|
|
386
133
|
healthy: false,
|
|
387
|
-
code: '
|
|
388
|
-
|
|
134
|
+
code: 'TUNNEL_ERROR',
|
|
135
|
+
tunnelErrorCode: 'DEBUGG_TUNNEL_UNKNOWN',
|
|
389
136
|
detail: 'no live debugg tunnel session in this process for this hostname',
|
|
390
137
|
elapsedMs: elapsed(),
|
|
391
138
|
},
|
|
@@ -416,8 +163,8 @@ async function probeOverControlChannel(tunnelUrl, timeoutMs, started) {
|
|
|
416
163
|
result: {
|
|
417
164
|
healthy: false,
|
|
418
165
|
status,
|
|
419
|
-
code: '
|
|
420
|
-
|
|
166
|
+
code: 'TUNNEL_ERROR',
|
|
167
|
+
tunnelErrorCode: marker,
|
|
421
168
|
detail: notFound
|
|
422
169
|
? `the tunnel server returned ${marker} — no client is connected for this tunnel`
|
|
423
170
|
: `the tunnel server returned ${marker} — tunnel established but traffic could not reach the dev server`,
|
|
@@ -443,8 +190,8 @@ async function probeOverControlChannel(tunnelUrl, timeoutMs, started) {
|
|
|
443
190
|
return { retryable: false, result: { healthy: true, status, elapsedMs: elapsed() } };
|
|
444
191
|
}
|
|
445
192
|
if (error === 'TIMEOUT') {
|
|
446
|
-
// Not retried
|
|
447
|
-
//
|
|
193
|
+
// Not retried: a hanging tunnel must not cost three times the timeout
|
|
194
|
+
// budget.
|
|
448
195
|
return {
|
|
449
196
|
retryable: false,
|
|
450
197
|
result: {
|
|
@@ -478,62 +225,36 @@ function probePathOf(tunnelUrl) {
|
|
|
478
225
|
return '/';
|
|
479
226
|
}
|
|
480
227
|
}
|
|
481
|
-
// ─
|
|
482
|
-
async function readCapped(res, maxBytes) {
|
|
483
|
-
if (!res.body)
|
|
484
|
-
return '';
|
|
485
|
-
const reader = res.body.getReader();
|
|
486
|
-
const decoder = new TextDecoder();
|
|
487
|
-
let total = 0;
|
|
488
|
-
let out = '';
|
|
489
|
-
try {
|
|
490
|
-
while (total < maxBytes) {
|
|
491
|
-
const { value, done } = await reader.read();
|
|
492
|
-
if (done)
|
|
493
|
-
break;
|
|
494
|
-
const remaining = maxBytes - total;
|
|
495
|
-
const chunk = value.length > remaining ? value.slice(0, remaining) : value;
|
|
496
|
-
out += decoder.decode(chunk, { stream: true });
|
|
497
|
-
total += chunk.length;
|
|
498
|
-
if (value.length > remaining) {
|
|
499
|
-
// Got enough; cancel the rest.
|
|
500
|
-
await reader.cancel().catch(() => { });
|
|
501
|
-
break;
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
out += decoder.decode();
|
|
505
|
-
}
|
|
506
|
-
catch {
|
|
507
|
-
/* ignore read errors — we return what we have */
|
|
508
|
-
}
|
|
509
|
-
return out;
|
|
510
|
-
}
|
|
228
|
+
// ─ Error markers ─────────────────────────────────────────────────────────────
|
|
511
229
|
/**
|
|
512
230
|
* The debugg tunnel server's error markers, spelled out rather than matched by
|
|
513
231
|
* a `DEBUGG_TUNNEL_[A-Z_]+` pattern.
|
|
514
232
|
*
|
|
515
233
|
* A pattern would also match this repo's own `DEBUGG_TUNNEL_FAULT_MODE` env var
|
|
516
|
-
* (services/
|
|
234
|
+
* (services/tunnel/tunnelFaultInjection.ts). This function is used on RUN
|
|
517
235
|
* EVIDENCE as well as on response bodies (testPageChangesHandler's
|
|
518
|
-
*
|
|
236
|
+
* findTunnelErrorMarker), so a log line mentioning that variable would otherwise
|
|
519
237
|
* be read as proof that the browser hit our error page, and a genuine UI
|
|
520
238
|
* failure would be reclassified as an infrastructure fault.
|
|
239
|
+
*
|
|
240
|
+
* ngrok's own error codes used to be matched here too. They went with the
|
|
241
|
+
* ngrok transport: no tunnel this client can create is served by ngrok, so
|
|
242
|
+
* matching them would be dead logic that reads as live — and worse, it would
|
|
243
|
+
* reclassify a genuine app failure as an infrastructure fault if a user's own
|
|
244
|
+
* page ever mentioned one.
|
|
521
245
|
*/
|
|
522
246
|
const DEBUGG_TUNNEL_MARKERS = Object.freeze([
|
|
523
247
|
'DEBUGG_TUNNEL_OFFLINE',
|
|
524
248
|
'DEBUGG_TUNNEL_UNKNOWN',
|
|
525
249
|
'DEBUGG_TUNNEL_UPSTREAM_REFUSED',
|
|
526
250
|
]);
|
|
527
|
-
const MARKER_PATTERN = new RegExp(
|
|
251
|
+
const MARKER_PATTERN = new RegExp(DEBUGG_TUNNEL_MARKERS.join('|'));
|
|
528
252
|
/**
|
|
529
|
-
* The stable error marker
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
* Name kept (and with it `TunnelHealthProbeResult.ngrokErrorCode` and
|
|
533
|
-
* `code: 'NGROK_ERROR'`) because handlers echo these into tool output;
|
|
534
|
-
* renaming them is part of deleting ngrok, not of adding a transport.
|
|
253
|
+
* The stable error marker the tunnel server put in front of the user's app,
|
|
254
|
+
* if any. Handlers echo it into tool output, so it is the one piece of
|
|
255
|
+
* evidence that distinguishes "our tunnel broke" from "the app failed".
|
|
535
256
|
*/
|
|
536
|
-
export function
|
|
257
|
+
export function extractTunnelErrorCode(body) {
|
|
537
258
|
const match = body.match(MARKER_PATTERN);
|
|
538
259
|
return match ? match[0] : undefined;
|
|
539
260
|
}
|
|
@@ -19,7 +19,7 @@ export function resolveByName(name, candidates, kind) {
|
|
|
19
19
|
if (matches.length > 1) {
|
|
20
20
|
return {
|
|
21
21
|
error: 'AmbiguousMatch',
|
|
22
|
-
message: `Multiple ${kind.toLowerCase().replace('testsuite', 'test suite')}s match "${name}"
|
|
22
|
+
message: `Multiple ${kind.toLowerCase().replace('testsuite', 'test suite')}s match "${name}".`,
|
|
23
23
|
candidates: matches.map(m => ({ uuid: m.uuid, name: m.name })),
|
|
24
24
|
};
|
|
25
25
|
}
|
package/dist/utils/telemetry.js
CHANGED
|
@@ -56,15 +56,15 @@ export const TelemetryEvents = {
|
|
|
56
56
|
TUNNEL_PROVISIONED: 'tunnel.provisioned',
|
|
57
57
|
TUNNEL_PROVISION_RETRY: 'tunnel.provision_retry',
|
|
58
58
|
TUNNEL_STOPPED: 'tunnel.stopped',
|
|
59
|
-
// services/
|
|
59
|
+
// services/tunnel/tunnelManager.ts — the session tunnel's Caddy instance
|
|
60
60
|
// crash-respawned onto a DIFFERENT local proxy port (sticky-port reclaim
|
|
61
|
-
// failed). The existing
|
|
61
|
+
// failed). The existing tunnel is now dialing a dead port, so the
|
|
62
62
|
// whole session tunnel is evicted and must be recreated on the next call
|
|
63
63
|
// (see docs/local-tunnel-multiplexer-architecture-2026-07-31.md §2.3).
|
|
64
64
|
TUNNEL_EVICTED_PORT_CHANGED: 'tunnel.evicted_port_changed',
|
|
65
|
-
// services/
|
|
65
|
+
// services/tunnel/tunnelManager.ts — stopTunnel()'s unconditional-removal
|
|
66
66
|
// contract (§2.3): map state is always removed before cleanup I/O runs, so
|
|
67
|
-
// a partial failure here (
|
|
67
|
+
// a partial failure here (tunnel disconnect / caddy.stop() / key revoke)
|
|
68
68
|
// never leaves stale-but-discoverable state. Fired once per failed step.
|
|
69
69
|
TUNNEL_TEARDOWN_PARTIAL_FAILURE: 'tunnel.teardown_partial_failure',
|
|
70
70
|
TEMPLATE_LOOKUP: 'template.lookup',
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Centralizes:
|
|
5
5
|
* - resolving user input url to a concrete URL
|
|
6
|
-
* - creating / reusing
|
|
6
|
+
* - creating / reusing tunnels after the backend returns a tunnelKey
|
|
7
7
|
* - acquiring/releasing this session's shared Caddy port route (§2.4)
|
|
8
8
|
* - sanitizing backend responses so callers only ever see the original URL
|
|
9
9
|
*/
|
|
10
|
-
import { tunnelManager, getSessionKey } from '../services/
|
|
10
|
+
import { tunnelManager, getSessionKey } from '../services/tunnel/tunnelManager.js';
|
|
11
11
|
import { isLocalhostUrl, replaceTunnelUrls, retargetTunnelUrl, extractLocalhostPort } from './urlParser.js';
|
|
12
12
|
// ─── URL resolution ──────────────────────────────────────────────────────────
|
|
13
13
|
/**
|
|
@@ -28,7 +28,7 @@ export function buildContext(originalUrl) {
|
|
|
28
28
|
}
|
|
29
29
|
// ─── Tunnel creation ─────────────────────────────────────────────────────────
|
|
30
30
|
/**
|
|
31
|
-
* Check whether this SESSION already has a tunnel (§2.1 — one
|
|
31
|
+
* Check whether this SESSION already has a tunnel (§2.1 — one tunnel
|
|
32
32
|
* per session key, not per local port). If found, touches its timer and
|
|
33
33
|
* returns an enriched context retargeted at this caller's own path. Returns
|
|
34
34
|
* null for public URLs or when this session has no tunnel yet.
|
|
@@ -57,14 +57,16 @@ export function findExistingTunnel(ctx) {
|
|
|
57
57
|
* No-op for public URLs.
|
|
58
58
|
*
|
|
59
59
|
* @param ctx - Context built from `buildContext()`
|
|
60
|
-
* @param tunnelKey - Auth token from the backend (short-lived
|
|
61
|
-
* @param tunnelId - ID to use as the
|
|
62
|
-
* first time this session creates a tunnel)
|
|
60
|
+
* @param tunnelKey - Auth token from the backend (short-lived tunnel token)
|
|
61
|
+
* @param tunnelId - ID to use as the tunnel hostname's label (only takes
|
|
62
|
+
* effect the first time this session creates a tunnel).
|
|
63
|
+
* The token is bound to it, so it must be the backend's.
|
|
63
64
|
* @param keyId - Backend key ID; stored on the tunnel so it is revoked on stop
|
|
64
65
|
* @param revokeKey - Callback that revokes the backend tunnel (called when tunnel stops)
|
|
65
|
-
* @param selection - The provision response's
|
|
66
|
-
*
|
|
67
|
-
*
|
|
66
|
+
* @param selection - The provision response's relayUrl / tunnelDomain /
|
|
67
|
+
* tunnelId. Omitting it leaves the transport with nowhere
|
|
68
|
+
* to connect, so every handler must pass the provision
|
|
69
|
+
* (locked by __tests__/handlers/tunnelSelectionWiring.test.ts).
|
|
68
70
|
*/
|
|
69
71
|
export async function ensureTunnel(ctx, tunnelKey, tunnelId, keyId, revokeKey, selection) {
|
|
70
72
|
if (!ctx.isLocalhost)
|