@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.
Files changed (34) hide show
  1. package/README.md +10 -4
  2. package/dist/handlers/createEnvironmentHandler.js +10 -0
  3. package/dist/handlers/probePageHandler.js +14 -7
  4. package/dist/handlers/runTestSuiteHandler.js +6 -5
  5. package/dist/handlers/testPageChangesHandler.js +20 -10
  6. package/dist/handlers/triggerCrawlHandler.js +7 -6
  7. package/dist/handlers/updateEnvironmentHandler.js +14 -2
  8. package/dist/services/index.js +21 -0
  9. package/dist/services/ngrok/tunnelManager.js +157 -161
  10. package/dist/services/tunnel/debuggTransport.js +439 -0
  11. package/dist/services/tunnel/ngrokTransport.js +165 -0
  12. package/dist/services/tunnel/probeRegistry.js +52 -0
  13. package/dist/services/tunnel/protocol/codec.js +327 -0
  14. package/dist/services/tunnel/protocol/conformance.js +171 -0
  15. package/dist/services/tunnel/protocol/constants.js +178 -0
  16. package/dist/services/tunnel/protocol/errors.js +51 -0
  17. package/dist/services/tunnel/protocol/handshake.js +134 -0
  18. package/dist/services/tunnel/protocol/index.js +17 -0
  19. package/dist/services/tunnel/protocol/session.js +668 -0
  20. package/dist/services/tunnel/protocol/stream.js +109 -0
  21. package/dist/services/tunnel/protocol/transport.js +106 -0
  22. package/dist/services/tunnel/transport.js +15 -0
  23. package/dist/services/tunnels.js +71 -3
  24. package/dist/services/verdictAdapter.js +25 -1
  25. package/dist/tools/environment.js +5 -2
  26. package/dist/tools/testPageChanges.js +1 -1
  27. package/dist/types/index.js +29 -2
  28. package/dist/utils/authorizedCredentialHosts.js +38 -0
  29. package/dist/utils/localReachability.js +160 -3
  30. package/dist/utils/tunnelContext.js +6 -3
  31. package/dist/utils/tunnelDisposition.js +16 -1
  32. package/dist/utils/tunnelDomains.js +113 -0
  33. package/dist/utils/urlParser.js +8 -2
  34. 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(['ERR_NGROK_3200']);
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
- // ngrok error pages surface codes like "ERR_NGROK_8012", "ERR_NGROK_3200", etc.
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 key (called when tunnel stops)
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(['ERR_NGROK_3200']);
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
+ }
@@ -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 ngrok tunnel URLs with the original localhost origin in any string/object.
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(/https?:\/\/[^\s/"]+\.ngrok\.debugg\.ai/g, localhostOrigin.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.2.2",
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",