@debugg-ai/debugg-ai-mcp 4.2.2 → 4.4.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 +10 -4
- package/dist/handlers/createEnvironmentHandler.js +10 -0
- package/dist/handlers/probePageHandler.js +14 -7
- package/dist/handlers/runTestSuiteHandler.js +6 -5
- package/dist/handlers/testPageChangesHandler.js +20 -10
- package/dist/handlers/triggerCrawlHandler.js +7 -6
- package/dist/handlers/updateEnvironmentHandler.js +14 -2
- package/dist/services/index.js +21 -0
- package/dist/services/ngrok/tunnelManager.js +157 -161
- package/dist/services/tunnel/debuggTransport.js +439 -0
- package/dist/services/tunnel/ngrokTransport.js +165 -0
- package/dist/services/tunnel/probeRegistry.js +52 -0
- package/dist/services/tunnel/protocol/codec.js +327 -0
- package/dist/services/tunnel/protocol/conformance.js +171 -0
- package/dist/services/tunnel/protocol/constants.js +178 -0
- package/dist/services/tunnel/protocol/errors.js +51 -0
- package/dist/services/tunnel/protocol/handshake.js +134 -0
- package/dist/services/tunnel/protocol/index.js +17 -0
- package/dist/services/tunnel/protocol/session.js +668 -0
- package/dist/services/tunnel/protocol/stream.js +109 -0
- package/dist/services/tunnel/protocol/transport.js +106 -0
- package/dist/services/tunnel/transport.js +15 -0
- package/dist/services/tunnels.js +71 -3
- package/dist/services/verdictAdapter.js +25 -1
- package/dist/tools/environment.js +5 -2
- package/dist/tools/testPageChanges.js +1 -1
- package/dist/types/index.js +29 -2
- package/dist/utils/authorizedCredentialHosts.js +38 -0
- package/dist/utils/localReachability.js +160 -3
- package/dist/utils/tunnelContext.js +6 -3
- package/dist/utils/tunnelDisposition.js +16 -1
- package/dist/utils/tunnelDomains.js +113 -0
- package/dist/utils/urlParser.js +8 -2
- package/package.json +3 -1
|
@@ -17,6 +17,8 @@ import { createConnection } from 'node:net';
|
|
|
17
17
|
import { request as httpRequest } from 'node:http';
|
|
18
18
|
import { request as httpsRequest } from 'node:https';
|
|
19
19
|
import { Readable } from 'node:stream';
|
|
20
|
+
import { tunnelDomainFor } from './tunnelDomains.js';
|
|
21
|
+
import { getControlProbe } from '../services/tunnel/probeRegistry.js';
|
|
20
22
|
export async function probeLocalPort(port, opts = {}) {
|
|
21
23
|
const host = opts.host ?? '127.0.0.1';
|
|
22
24
|
const timeoutMs = opts.timeoutMs ?? 1500;
|
|
@@ -91,7 +93,16 @@ const TRANSIENT_CAUSE_CODES = new Set([
|
|
|
91
93
|
* other, and coupling them would make this module depend on policy it has no
|
|
92
94
|
* business knowing.
|
|
93
95
|
*/
|
|
94
|
-
const ENDPOINT_NOT_FOUND_CODES = new Set([
|
|
96
|
+
const ENDPOINT_NOT_FOUND_CODES = new Set([
|
|
97
|
+
'ERR_NGROK_3200',
|
|
98
|
+
// The debugg analogue. The server already holds a browser request for
|
|
99
|
+
// RECONNECT_GRACE_MS (5s) while a client reconnects before rendering this,
|
|
100
|
+
// but a reconnect that outlasts the grace window would otherwise evict a
|
|
101
|
+
// tunnel that is about to come back, so confirm it across the ladder too.
|
|
102
|
+
// DEBUGG_TUNNEL_UNKNOWN is deliberately NOT here: the id is gone, and
|
|
103
|
+
// re-probing cannot change that answer.
|
|
104
|
+
'DEBUGG_TUNNEL_OFFLINE',
|
|
105
|
+
]);
|
|
95
106
|
// ─ HTTP/1.1 probe transport (bead kmzb) ──────────────────────────────────────
|
|
96
107
|
/**
|
|
97
108
|
* `fetch`-shaped GET that speaks HTTP/1.1, because the global fetch cannot.
|
|
@@ -241,6 +252,16 @@ export async function probeTunnelHealth(tunnelUrl, opts = {}) {
|
|
|
241
252
|
}
|
|
242
253
|
async function probeOnce(tunnelUrl, opts, started) {
|
|
243
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
|
+
}
|
|
244
265
|
// Bead kmzb: HTTP/1.1, not the global fetch — see http1Fetch.
|
|
245
266
|
const fetchImpl = opts.fetchFn ?? http1Fetch;
|
|
246
267
|
const controller = new AbortController();
|
|
@@ -345,6 +366,118 @@ async function probeOnce(tunnelUrl, opts, started) {
|
|
|
345
366
|
};
|
|
346
367
|
}
|
|
347
368
|
}
|
|
369
|
+
// ─ Control-channel probe (debugg transport) ──────────────────────────────────
|
|
370
|
+
/**
|
|
371
|
+
* Ask the tunnel server to fetch `path` back through this tunnel and report
|
|
372
|
+
* what it got. Maps PROBE_RESULT onto the same TunnelHealthProbeResult the
|
|
373
|
+
* HTTP path produces — the mapping is pinned on bead debugg_ai_mcp-xkoh.1.2 §9
|
|
374
|
+
* and must not be re-derived here.
|
|
375
|
+
*/
|
|
376
|
+
async function probeOverControlChannel(tunnelUrl, timeoutMs, started) {
|
|
377
|
+
const elapsed = () => Date.now() - started;
|
|
378
|
+
const probe = getControlProbe(tunnelUrl);
|
|
379
|
+
if (!probe) {
|
|
380
|
+
// No live control channel in this process for that hostname, and the URL
|
|
381
|
+
// is unreachable any other way. The tunnel is not coming back on its own,
|
|
382
|
+
// so report it gone and let the disposition policy clear the leftovers.
|
|
383
|
+
return {
|
|
384
|
+
retryable: false,
|
|
385
|
+
result: {
|
|
386
|
+
healthy: false,
|
|
387
|
+
code: 'NGROK_ERROR',
|
|
388
|
+
ngrokErrorCode: 'DEBUGG_TUNNEL_UNKNOWN',
|
|
389
|
+
detail: 'no live debugg tunnel session in this process for this hostname',
|
|
390
|
+
elapsedMs: elapsed(),
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
let probeResult;
|
|
395
|
+
try {
|
|
396
|
+
probeResult = await probe(probePathOf(tunnelUrl), { timeoutMs });
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
// The probe API's contract is that it never throws. Belt and braces: a
|
|
400
|
+
// probe we could not run is not evidence of a fault.
|
|
401
|
+
return {
|
|
402
|
+
retryable: true,
|
|
403
|
+
result: {
|
|
404
|
+
healthy: false,
|
|
405
|
+
code: 'NETWORK_ERROR',
|
|
406
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
407
|
+
elapsedMs: elapsed(),
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
const { status, marker, error } = probeResult;
|
|
412
|
+
if (marker) {
|
|
413
|
+
const notFound = ENDPOINT_NOT_FOUND_CODES.has(marker);
|
|
414
|
+
return {
|
|
415
|
+
retryable: notFound,
|
|
416
|
+
result: {
|
|
417
|
+
healthy: false,
|
|
418
|
+
status,
|
|
419
|
+
code: 'NGROK_ERROR',
|
|
420
|
+
ngrokErrorCode: marker,
|
|
421
|
+
detail: notFound
|
|
422
|
+
? `the tunnel server returned ${marker} — no client is connected for this tunnel`
|
|
423
|
+
: `the tunnel server returned ${marker} — tunnel established but traffic could not reach the dev server`,
|
|
424
|
+
elapsedMs: elapsed(),
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
if (status !== undefined) {
|
|
429
|
+
if (status === 502 || status === 504) {
|
|
430
|
+
return {
|
|
431
|
+
retryable: false,
|
|
432
|
+
result: {
|
|
433
|
+
healthy: false,
|
|
434
|
+
status,
|
|
435
|
+
code: 'BAD_GATEWAY',
|
|
436
|
+
detail: `tunnel returned ${status} without an error marker — gateway is rejecting upstream`,
|
|
437
|
+
elapsedMs: elapsed(),
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
// Any other status means traffic reached the dev server, which is healthy
|
|
442
|
+
// from the TUNNEL's point of view. A user's own 404 is a user concern.
|
|
443
|
+
return { retryable: false, result: { healthy: true, status, elapsedMs: elapsed() } };
|
|
444
|
+
}
|
|
445
|
+
if (error === 'TIMEOUT') {
|
|
446
|
+
// Not retried, for the same reason the HTTP path does not: a hanging
|
|
447
|
+
// tunnel must not cost three times the timeout budget.
|
|
448
|
+
return {
|
|
449
|
+
retryable: false,
|
|
450
|
+
result: {
|
|
451
|
+
healthy: false,
|
|
452
|
+
code: 'TIMEOUT',
|
|
453
|
+
detail: `tunnel health probe timed out after ${timeoutMs}ms`,
|
|
454
|
+
elapsedMs: elapsed(),
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
// Anything else (RESET, CLOSED, ...) is a connection-level failure. It is
|
|
459
|
+
// retried, because a session that is mid-reconnect reports exactly this and
|
|
460
|
+
// recovers within the ladder.
|
|
461
|
+
return {
|
|
462
|
+
retryable: true,
|
|
463
|
+
result: {
|
|
464
|
+
healthy: false,
|
|
465
|
+
code: 'NETWORK_ERROR',
|
|
466
|
+
detail: `tunnel control channel reported ${error ?? 'an unknown failure'}`,
|
|
467
|
+
elapsedMs: elapsed(),
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
/** The path (with search) a probe should request, defaulting to root. */
|
|
472
|
+
function probePathOf(tunnelUrl) {
|
|
473
|
+
try {
|
|
474
|
+
const url = new URL(tunnelUrl);
|
|
475
|
+
return `${url.pathname}${url.search}` || '/';
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
return '/';
|
|
479
|
+
}
|
|
480
|
+
}
|
|
348
481
|
// ─ helpers ───────────────────────────────────────────────────────────────────
|
|
349
482
|
async function readCapped(res, maxBytes) {
|
|
350
483
|
if (!res.body)
|
|
@@ -375,8 +508,32 @@ async function readCapped(res, maxBytes) {
|
|
|
375
508
|
}
|
|
376
509
|
return out;
|
|
377
510
|
}
|
|
511
|
+
/**
|
|
512
|
+
* The debugg tunnel server's error markers, spelled out rather than matched by
|
|
513
|
+
* a `DEBUGG_TUNNEL_[A-Z_]+` pattern.
|
|
514
|
+
*
|
|
515
|
+
* A pattern would also match this repo's own `DEBUGG_TUNNEL_FAULT_MODE` env var
|
|
516
|
+
* (services/ngrok/tunnelFaultInjection.ts). This function is used on RUN
|
|
517
|
+
* EVIDENCE as well as on response bodies (testPageChangesHandler's
|
|
518
|
+
* findNgrokErrorMarker), so a log line mentioning that variable would otherwise
|
|
519
|
+
* be read as proof that the browser hit our error page, and a genuine UI
|
|
520
|
+
* failure would be reclassified as an infrastructure fault.
|
|
521
|
+
*/
|
|
522
|
+
const DEBUGG_TUNNEL_MARKERS = Object.freeze([
|
|
523
|
+
'DEBUGG_TUNNEL_OFFLINE',
|
|
524
|
+
'DEBUGG_TUNNEL_UNKNOWN',
|
|
525
|
+
'DEBUGG_TUNNEL_UPSTREAM_REFUSED',
|
|
526
|
+
]);
|
|
527
|
+
const MARKER_PATTERN = new RegExp(`ERR_NGROK_\\d+|${DEBUGG_TUNNEL_MARKERS.join('|')}`);
|
|
528
|
+
/**
|
|
529
|
+
* The stable error marker a tunnel put in front of the user's app, from either
|
|
530
|
+
* transport: ngrok's `ERR_NGROK_*` or the debugg server's `DEBUGG_TUNNEL_*`.
|
|
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.
|
|
535
|
+
*/
|
|
378
536
|
export function extractNgrokErrorCode(body) {
|
|
379
|
-
|
|
380
|
-
const match = body.match(/ERR_NGROK_\d+/);
|
|
537
|
+
const match = body.match(MARKER_PATTERN);
|
|
381
538
|
return match ? match[0] : undefined;
|
|
382
539
|
}
|
|
@@ -61,12 +61,15 @@ export function findExistingTunnel(ctx) {
|
|
|
61
61
|
* @param tunnelId - ID to use as the ngrok subdomain (only takes effect the
|
|
62
62
|
* first time this session creates a tunnel)
|
|
63
63
|
* @param keyId - Backend key ID; stored on the tunnel so it is revoked on stop
|
|
64
|
-
* @param revokeKey - Callback that revokes the backend
|
|
64
|
+
* @param revokeKey - Callback that revokes the backend tunnel (called when tunnel stops)
|
|
65
|
+
* @param selection - The provision response's transport fields. Omitted means
|
|
66
|
+
* ngrok, which is what an old backend implies, so every
|
|
67
|
+
* existing caller keeps its meaning.
|
|
65
68
|
*/
|
|
66
|
-
export async function ensureTunnel(ctx, tunnelKey, tunnelId, keyId, revokeKey) {
|
|
69
|
+
export async function ensureTunnel(ctx, tunnelKey, tunnelId, keyId, revokeKey, selection) {
|
|
67
70
|
if (!ctx.isLocalhost)
|
|
68
71
|
return ctx;
|
|
69
|
-
const info = await tunnelManager.ensureSessionTunnel(getSessionKey(), tunnelKey, tunnelId, keyId, revokeKey);
|
|
72
|
+
const info = await tunnelManager.ensureSessionTunnel(getSessionKey(), tunnelKey, tunnelId, keyId, revokeKey, selection);
|
|
70
73
|
return { ...ctx, tunnelId: info.tunnelId, targetUrl: retargetTunnelUrl(info.tunnelUrl, ctx.originalUrl) };
|
|
71
74
|
}
|
|
72
75
|
/**
|
|
@@ -72,7 +72,22 @@ const logger = new Logger({ module: 'tunnelDisposition' });
|
|
|
72
72
|
* a "frozen" Set still succeeds silently. Freezing the array is a real runtime
|
|
73
73
|
* guarantee; the lookup Set is derived from it and kept private.
|
|
74
74
|
*/
|
|
75
|
-
const ENDPOINT_GONE_CODES = Object.freeze([
|
|
75
|
+
const ENDPOINT_GONE_CODES = Object.freeze([
|
|
76
|
+
'ERR_NGROK_3200',
|
|
77
|
+
// The debugg tunnel server's equivalents (bead debugg_ai_mcp-xkoh.1.2 §10).
|
|
78
|
+
// Both are served BY THE EDGE ABOUT A HOSTNAME IT DOES NOT ROUTE, which is
|
|
79
|
+
// the inclusion criterion above:
|
|
80
|
+
// DEBUGG_TUNNEL_OFFLINE — no client is connected. Confirmed across
|
|
81
|
+
// probeTunnelHealth's retry ladder before it gets here, because the
|
|
82
|
+
// server renders it after a 5s grace window that a slow reconnect can
|
|
83
|
+
// outlast.
|
|
84
|
+
// DEBUGG_TUNNEL_UNKNOWN — unknown or revoked tunnel id. Definitive.
|
|
85
|
+
// DEBUGG_TUNNEL_UPSTREAM_REFUSED is deliberately absent: like ERR_NGROK_8012
|
|
86
|
+
// it means the TUNNEL served us that page and is alive, while the user's dev
|
|
87
|
+
// server is what refused.
|
|
88
|
+
'DEBUGG_TUNNEL_OFFLINE',
|
|
89
|
+
'DEBUGG_TUNNEL_UNKNOWN',
|
|
90
|
+
]);
|
|
76
91
|
const ENDPOINT_GONE_LOOKUP = new Set(ENDPOINT_GONE_CODES);
|
|
77
92
|
/** The allowlist, as an immutable list. Read-only by construction. */
|
|
78
93
|
export const ENDPOINT_GONE_NGROK_CODES = ENDPOINT_GONE_CODES;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hostnames a tunnel can live on, and which transport serves each one.
|
|
3
|
+
*
|
|
4
|
+
* During the migration a single MCP process can hold an ngrok tunnel and a
|
|
5
|
+
* debugg tunnel at the same time, so every place that recognises a tunnel
|
|
6
|
+
* hostname has to know both. The one that matters to users is
|
|
7
|
+
* `replaceTunnelUrls`: it is what keeps a tunnel hostname out of every tool
|
|
8
|
+
* response, so a domain it does not know is a URL that leaks to the caller.
|
|
9
|
+
*
|
|
10
|
+
* A provision response may name a domain we do not ship (the backend moving
|
|
11
|
+
* `tunnel.debugg.ai` elsewhere, or a staging host), so domains can be
|
|
12
|
+
* registered at runtime — that is what lets the server move without an MCP
|
|
13
|
+
* release, the same reasoning as `relayUrl`.
|
|
14
|
+
*
|
|
15
|
+
* Imports NOTHING, deliberately: utils/urlParser.ts, utils/localReachability.ts
|
|
16
|
+
* and services/ngrok/tunnelManager.ts all depend on it, and it must never be
|
|
17
|
+
* the module that creates a cycle.
|
|
18
|
+
*/
|
|
19
|
+
const BUILT_IN_DOMAINS = Object.freeze([
|
|
20
|
+
{ domain: 'ngrok.debugg.ai', transport: 'ngrok' },
|
|
21
|
+
{ domain: 'tunnel.debugg.ai', transport: 'debugg' },
|
|
22
|
+
]);
|
|
23
|
+
const domains = new Map(BUILT_IN_DOMAINS.map((d) => [d.domain, d.transport]));
|
|
24
|
+
/**
|
|
25
|
+
* A tunnel domain must be a lowercase DNS name of at least THREE labels.
|
|
26
|
+
*
|
|
27
|
+
* The label count is a safety rule, not a style one: a registered domain
|
|
28
|
+
* becomes a rewrite target, so accepting `debugg.ai` would make
|
|
29
|
+
* `replaceTunnelUrls` rewrite every real product link in a backend response
|
|
30
|
+
* (`https://app.debugg.ai/runs/1`) into the caller's localhost origin.
|
|
31
|
+
*/
|
|
32
|
+
export function isValidTunnelDomain(domain) {
|
|
33
|
+
if (typeof domain !== 'string')
|
|
34
|
+
return false;
|
|
35
|
+
if (domain.length > 253)
|
|
36
|
+
return false;
|
|
37
|
+
const labels = domain.split('.');
|
|
38
|
+
if (labels.length < 3)
|
|
39
|
+
return false;
|
|
40
|
+
return labels.every((label) => /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Teach this process about a tunnel domain. Idempotent, and safe to call on
|
|
44
|
+
* every provision. Returns false for a domain that fails validation, so a
|
|
45
|
+
* caller can turn that into an error rather than silently trusting it.
|
|
46
|
+
*/
|
|
47
|
+
export function registerTunnelDomain(domain, transport) {
|
|
48
|
+
if (!isValidTunnelDomain(domain))
|
|
49
|
+
return false;
|
|
50
|
+
domains.set(domain, transport);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
/** Every known domain, built-in and registered. */
|
|
54
|
+
export function knownTunnelDomains() {
|
|
55
|
+
return [...domains.entries()].map(([domain, transport]) => ({ domain, transport }));
|
|
56
|
+
}
|
|
57
|
+
/** The known domain a host sits under, or undefined. Accepts a host or a full URL. */
|
|
58
|
+
export function tunnelDomainFor(hostOrUrl) {
|
|
59
|
+
const host = hostOf(hostOrUrl);
|
|
60
|
+
if (!host)
|
|
61
|
+
return undefined;
|
|
62
|
+
for (const [domain, transport] of domains) {
|
|
63
|
+
// A tunnel host is always `<label>.<domain>` — never the bare domain.
|
|
64
|
+
if (host.length > domain.length + 1 && host.endsWith(`.${domain}`)) {
|
|
65
|
+
return { domain, transport };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
export function isTunnelHost(hostOrUrl) {
|
|
71
|
+
return tunnelDomainFor(hostOrUrl) !== undefined;
|
|
72
|
+
}
|
|
73
|
+
/** The tunnel id (the leftmost label) of a tunnel host, or null. */
|
|
74
|
+
export function extractTunnelIdFromHost(hostOrUrl) {
|
|
75
|
+
const host = hostOf(hostOrUrl);
|
|
76
|
+
const info = host ? tunnelDomainFor(host) : undefined;
|
|
77
|
+
if (!host || !info)
|
|
78
|
+
return null;
|
|
79
|
+
const label = host.slice(0, host.length - info.domain.length - 1);
|
|
80
|
+
return label.includes('.') ? null : label;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* `https?://<label>.<known domain>` as one alternation, for whole-string
|
|
84
|
+
* rewriting. Rebuilt per call because the set can grow at runtime; these are
|
|
85
|
+
* short lists and the callers are not hot loops.
|
|
86
|
+
*/
|
|
87
|
+
export function tunnelUrlPattern() {
|
|
88
|
+
const alternatives = [...domains.keys()].map(escapeRegExp).join('|');
|
|
89
|
+
return new RegExp(`https?:\\/\\/[^\\s/"']+\\.(?:${alternatives})`, 'g');
|
|
90
|
+
}
|
|
91
|
+
function hostOf(hostOrUrl) {
|
|
92
|
+
if (typeof hostOrUrl !== 'string' || hostOrUrl.length === 0)
|
|
93
|
+
return undefined;
|
|
94
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(hostOrUrl)) {
|
|
95
|
+
try {
|
|
96
|
+
return new URL(hostOrUrl).hostname.toLowerCase();
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// A bare host, possibly with a port.
|
|
103
|
+
return hostOrUrl.split('/')[0].split(':')[0].toLowerCase();
|
|
104
|
+
}
|
|
105
|
+
function escapeRegExp(value) {
|
|
106
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
107
|
+
}
|
|
108
|
+
/** Test hook: drop every runtime-registered domain, keeping the built-ins. */
|
|
109
|
+
export function _resetTunnelDomainsForTests() {
|
|
110
|
+
domains.clear();
|
|
111
|
+
for (const { domain, transport } of BUILT_IN_DOMAINS)
|
|
112
|
+
domains.set(domain, transport);
|
|
113
|
+
}
|
package/dist/utils/urlParser.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* URL Parser Utilities
|
|
3
3
|
* Helper functions for parsing and validating URLs, specifically for detecting localhost URLs
|
|
4
4
|
*/
|
|
5
|
+
import { tunnelUrlPattern } from './tunnelDomains.js';
|
|
5
6
|
/**
|
|
6
7
|
* Normalize a user-supplied URL string before validation.
|
|
7
8
|
* Handles bare hostnames without a scheme (e.g. "localhost:3000" → "http://localhost:3000").
|
|
@@ -87,13 +88,18 @@ export function isLocalhostUrl(urlString) {
|
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
90
|
/**
|
|
90
|
-
* Replace
|
|
91
|
+
* Replace tunnel URLs with the original localhost origin in any string/object.
|
|
91
92
|
* Used to sanitize backend responses that contain internal tunnel URLs before
|
|
92
93
|
* returning them to callers who only know the original localhost address.
|
|
94
|
+
*
|
|
95
|
+
* Covers EVERY known tunnel domain (utils/tunnelDomains.ts), not just ngrok's:
|
|
96
|
+
* during the migration one session can hold an ngrok tunnel and another a
|
|
97
|
+
* debugg tunnel, and a domain this does not know is a hostname that leaks to
|
|
98
|
+
* the caller.
|
|
93
99
|
*/
|
|
94
100
|
export function replaceTunnelUrls(value, localhostOrigin) {
|
|
95
101
|
if (typeof value === 'string') {
|
|
96
|
-
return value.replace(
|
|
102
|
+
return value.replace(tunnelUrlPattern(), localhostOrigin.replace(/\/$/, ''));
|
|
97
103
|
}
|
|
98
104
|
if (Array.isArray(value)) {
|
|
99
105
|
return value.map(item => replaceTunnelUrls(item, localhostOrigin));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@debugg-ai/debugg-ai-mcp",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"description": "Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"caddy": "2.11.3",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"posthog-node": "^5.26.0",
|
|
66
66
|
"uuid": "^11.1.0",
|
|
67
67
|
"winston": "^3.15.0",
|
|
68
|
+
"ws": "^8.21.3",
|
|
68
69
|
"zod": "^3.25.32"
|
|
69
70
|
},
|
|
70
71
|
"devDependencies": {
|
|
@@ -73,6 +74,7 @@
|
|
|
73
74
|
"@types/jest": "^30.0.0",
|
|
74
75
|
"@types/node": "^22.15.24",
|
|
75
76
|
"@types/uuid": "^10.0.0",
|
|
77
|
+
"@types/ws": "^8.18.1",
|
|
76
78
|
"eslint": "^10.2.0",
|
|
77
79
|
"jest": "^30.0.5",
|
|
78
80
|
"jest-environment-jsdom": "^30.0.5",
|