@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +32 -14
  3. package/dist/handlers/createEnvironmentHandler.js +4 -4
  4. package/dist/handlers/createProjectHandler.js +1 -1
  5. package/dist/handlers/environmentSessionsHandler.js +2 -18
  6. package/dist/handlers/probePageHandler.js +10 -13
  7. package/dist/handlers/runTestSuiteHandler.js +8 -9
  8. package/dist/handlers/searchEnvironmentsHandler.js +3 -6
  9. package/dist/handlers/searchExecutionsHandler.js +3 -11
  10. package/dist/handlers/searchProjectsHandler.js +1 -6
  11. package/dist/handlers/testPageChangesHandler.js +88 -183
  12. package/dist/handlers/triggerCrawlHandler.js +52 -44
  13. package/dist/handlers/updateEnvironmentHandler.js +2 -2
  14. package/dist/index.js +1 -1
  15. package/dist/services/caddy/caddyProxy.js +7 -6
  16. package/dist/services/index.js +18 -21
  17. package/dist/services/tunnel/debuggTransport.js +9 -9
  18. package/dist/services/tunnel/protocol/constants.js +3 -3
  19. package/dist/services/tunnel/transport.js +28 -9
  20. package/dist/services/{ngrok → tunnel}/tunnelFaultInjection.js +9 -15
  21. package/dist/services/{ngrok → tunnel}/tunnelManager.js +65 -131
  22. package/dist/services/{ngrok → tunnel}/tunnelRegistry.js +2 -2
  23. package/dist/services/tunnels.js +64 -46
  24. package/dist/services/verdictAdapter.js +167 -41
  25. package/dist/services/workflows.js +10 -1
  26. package/dist/tools/testPageChanges.js +1 -1
  27. package/dist/utils/authorizedCredentialHosts.js +8 -20
  28. package/dist/utils/confirmDestructive.js +1 -2
  29. package/dist/utils/errors.js +4 -5
  30. package/dist/utils/imageUtils.js +2 -2
  31. package/dist/utils/localReachability.js +62 -341
  32. package/dist/utils/resolveProject.js +1 -1
  33. package/dist/utils/telemetry.js +4 -4
  34. package/dist/utils/tunnelContext.js +11 -9
  35. package/dist/utils/tunnelDisposition.js +51 -69
  36. package/dist/utils/tunnelDomains.js +30 -21
  37. package/dist/utils/urlParser.js +38 -6
  38. package/package.json +2 -3
  39. package/dist/services/ngrok/error.js +0 -3
  40. package/dist/services/ngrok/ngrokAgentInspector.js +0 -210
  41. package/dist/services/ngrok/ngrokAgentSession.js +0 -49
  42. package/dist/services/ngrok/types.js +0 -1
  43. package/dist/services/tunnel/ngrokTransport.js +0 -165
@@ -1,70 +1,64 @@
1
1
  /**
2
2
  * What to do with a tunnel whose health probe just failed.
3
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.
4
+ * A teardown has to be backed by PROOF that the tunnel is already gone — not
5
+ * by a probe result that a transient flake produces just as readily as a real
6
+ * death. Tearing down a live tunnel is not free: the next call has to
7
+ * re-provision, re-handshake and re-point Caddy, and it does that while the
8
+ * user's actual problem (a dev server that is down, bound to the wrong
9
+ * interface, or still starting) is untouched. It converts "your app isn't
10
+ * answering" into "your app isn't answering AND your tunnel churned".
11
+ *
12
+ * (Under ngrok the same rule had a sharper edge — its minimum billing unit was
13
+ * one hour per tunnel, so a needless teardown cost two billed hours. We own
14
+ * the tunnel server now, so the cost is latency and churn rather than money.
15
+ * The policy is unchanged; only the size of the bill is.)
8
16
  *
9
17
  * 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:
18
+ * browser, and all four used to gate eviction on the error code being set AT ALL,
19
+ * falling back to stopTunnel() otherwise. Both halves were wrong:
12
20
  *
13
- * - `ngrokErrorCode` is not a verdict. ERR_NGROK_8012 means the tunnel is ALIVE
21
+ * - an error code is not a verdict. UPSTREAM_REFUSED means the tunnel is ALIVE
14
22
  * 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.
23
+ * served us that error. Evicting on it orphans a working tunnel and makes
24
+ * the next call provision a replacement, to work around a dev server that
25
+ * was the actual problem.
18
26
  * - 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).
27
+ * including a transient connection-level flake (bead k6yq — measured at
28
+ * roughly 1 run in 5 before the retry ladder landed, and still reachable
29
+ * whenever that ladder exhausts).
22
30
  *
23
31
  * Hence an explicit allowlist of codes that prove the ENDPOINT ITSELF is gone.
24
32
  * Everything else leaves both the tunnel and the shared registry entry completely
25
33
  * 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_*).
34
+ * server and the next call reuses the same tunnel.
37
35
  */
38
- import { tunnelManager } from '../services/ngrok/tunnelManager.js';
36
+ import { tunnelManager } from '../services/tunnel/tunnelManager.js';
39
37
  import { Logger } from './logger.js';
40
38
  const logger = new Logger({ module: 'tunnelDisposition' });
41
39
  /**
42
- * ngrok error codes that PROVE the endpoint no longer exists, so evicting it
43
- * costs nothing and re-borrowing it costs everyone a failed run (bead k34o).
44
- *
45
- * Inclusion criterion — the code must be served BY THE EDGE ABOUT A HOSTNAME IT
46
- * NO LONGER ROUTES, i.e. it is impossible to receive it from a tunnel that is
47
- * still up. Anything describing the upstream, the agent, or the connection is a
48
- * live tunnel reporting on something else, and must NOT be here.
40
+ * Tunnel-server error markers that PROVE the endpoint no longer exists, so
41
+ * evicting it costs nothing and keeping it costs the next call a failed run.
49
42
  *
50
- * Verify a candidate before adding it — the check is free and creates no tunnel:
51
- * curl -s https://<random-uuid>.ngrok.debugg.ai/ | grep -o 'ERR_NGROK_[0-9]*'
52
- * (use curl or node:https, NOT fetch — see the kmzb note above).
43
+ * Inclusion criterion — the marker must be served BY THE EDGE ABOUT A TUNNEL IT
44
+ * CANNOT ROUTE, i.e. it is impossible to receive it from a tunnel that is still
45
+ * up. Anything describing the upstream, the client, or the connection is a live
46
+ * tunnel reporting on something else, and must NOT be here.
53
47
  *
54
- * ERR_NGROK_3200 — "not found". Verified live 2026-07-27: a GET to a
55
- * nonexistent *.ngrok.debugg.ai host returns 404 with this code in the body.
56
- * The endpoint is definitively gone; nothing is billing for it.
48
+ * DEBUGG_TUNNEL_OFFLINE — no client is connected. Confirmed across
49
+ * probeTunnelHealth's retry ladder before it gets here, because the server
50
+ * renders it after a 5s grace window that a slow reconnect can outlast.
51
+ * DEBUGG_TUNNEL_UNKNOWN — unknown or revoked tunnel id. Definitive.
57
52
  *
58
53
  * Deliberately EXCLUDED, and the sharpest case of all:
59
54
  *
60
- * ERR_NGROK_8012 — the agent could not dial the upstream (connection refused).
61
- * The TUNNEL IS ALIVE; it is the thing that generated the error page. Evicting
62
- * on 8012 orphans a paid-for tunnel and buys a duplicate — two billed hours —
63
- * to work around a dev server that is down, bound to the wrong interface, or
64
- * still starting up. Leave it be; it will serve the very next request once the
65
- * user's server is back.
55
+ * DEBUGG_TUNNEL_UPSTREAM_REFUSED — the client could not dial the local app.
56
+ * The TUNNEL IS ALIVE; it is the thing that generated the error page.
57
+ * Evicting on it throws away a working tunnel to work around a dev server
58
+ * that is down, bound to the wrong interface, or still starting up. Leave it
59
+ * be; it will serve the very next request once the user's server is back.
66
60
  *
67
- * This set is pinned by a test. Adding a code has to be a deliberate act with
61
+ * This set is pinned by a test. Adding a marker has to be a deliberate act with
68
62
  * evidence behind it, not a silent default to teardown.
69
63
  *
70
64
  * The frozen ARRAY is the source of truth, not a frozen Set: Object.freeze does
@@ -73,31 +67,19 @@ const logger = new Logger({ module: 'tunnelDisposition' });
73
67
  * guarantee; the lookup Set is derived from it and kept private.
74
68
  */
75
69
  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
70
  'DEBUGG_TUNNEL_OFFLINE',
89
71
  'DEBUGG_TUNNEL_UNKNOWN',
90
72
  ]);
91
73
  const ENDPOINT_GONE_LOOKUP = new Set(ENDPOINT_GONE_CODES);
92
74
  /** The allowlist, as an immutable list. Read-only by construction. */
93
- export const ENDPOINT_GONE_NGROK_CODES = ENDPOINT_GONE_CODES;
75
+ export const ENDPOINT_GONE_TUNNEL_CODES = ENDPOINT_GONE_CODES;
94
76
  /**
95
- * True only when the code proves the endpoint is gone. Unset / unknown codes are
77
+ * True only when the marker proves the endpoint is gone. Unset / unknown codes are
96
78
  * NOT proof of anything, so they answer false: the default is always to keep the
97
- * tunnel we are already paying for.
79
+ * tunnel we already have.
98
80
  */
99
- export function isEndpointGone(ngrokErrorCode) {
100
- return !!ngrokErrorCode && ENDPOINT_GONE_LOOKUP.has(ngrokErrorCode);
81
+ export function isEndpointGone(tunnelErrorCode) {
82
+ return !!tunnelErrorCode && ENDPOINT_GONE_LOOKUP.has(tunnelErrorCode);
101
83
  }
102
84
  /**
103
85
  * Decide — once, in one place — what an unhealthy tunnel health probe does to the
@@ -114,8 +96,8 @@ export function isEndpointGone(ngrokErrorCode) {
114
96
  * tunnel half retired along with cross-process borrowing.
115
97
  * Anything else → nothing at all. The caller still returns
116
98
  * TunnelTrafficBlocked, so the user is told; we simply do
117
- * not spend two billed hours acting on a verdict this
118
- * probe cannot actually deliver.
99
+ * not tear down a tunnel on a verdict this probe cannot
100
+ * actually deliver.
119
101
  *
120
102
  * Never throws and never awaits the eviction: a cleanup decision must not be able
121
103
  * to fail or slow down the error response the caller is about to return.
@@ -124,12 +106,12 @@ export function disposeUnhealthyTunnel(args) {
124
106
  const { health, tunnelId, originalUrl } = args;
125
107
  if (!tunnelId)
126
108
  return;
127
- if (!isEndpointGone(health.ngrokErrorCode)) {
128
- logger.info(`Tunnel ${tunnelId} failed its health probe (${health.code}${health.ngrokErrorCode ? ` ${health.ngrokErrorCode}` : ''}) ` +
129
- 'but nothing proves the endpoint is gone — keeping it. Tearing down a live tunnel costs two billed hours ' +
130
- '(1-hour minimum down, another up) and the next call reuses this one for free.');
109
+ if (!isEndpointGone(health.tunnelErrorCode)) {
110
+ logger.info(`Tunnel ${tunnelId} failed its health probe (${health.code}${health.tunnelErrorCode ? ` ${health.tunnelErrorCode}` : ''}) ` +
111
+ 'but nothing proves the endpoint is gone — keeping it. Tearing down a live tunnel makes the next ' +
112
+ 'call re-provision and re-handshake for nothing, while the next call can reuse this one as is.');
131
113
  return;
132
114
  }
133
- logger.warn(`Tunnel ${tunnelId} (${originalUrl}) reported ${health.ngrokErrorCode} — the endpoint is gone, evicting it.`);
115
+ logger.warn(`Tunnel ${tunnelId} (${originalUrl}) reported ${health.tunnelErrorCode} — the endpoint is gone, evicting it.`);
134
116
  tunnelManager.markTunnelDead(tunnelId).catch((err) => logger.warn(`Failed to evict dead tunnel ${tunnelId}: ${err}`));
135
117
  }
@@ -1,26 +1,35 @@
1
1
  /**
2
- * The hostnames a tunnel can live on, and which transport serves each one.
2
+ * The hostnames a tunnel URL can live on.
3
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.
4
+ * What this is FOR: `replaceTunnelUrls` reads this list to keep a tunnel
5
+ * hostname out of every tool response, so a domain it does not know is a URL
6
+ * that leaks to the caller.
9
7
  *
10
8
  * 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
9
+ * `tunnel.debugg.ai` elsewhere, or a local dev host), so domains can be
12
10
  * registered at runtime — that is what lets the server move without an MCP
13
11
  * release, the same reasoning as `relayUrl`.
14
12
  *
13
+ * ┌─ DO NOT DELETE `ngrok.debugg.ai` AS DEAD CODE ─────────────────────────────┐
14
+ * │ This client can no longer CREATE an ngrok tunnel — the ngrok transport was │
15
+ * │ deleted. `ngrok.debugg.ai` stays here purely as a REWRITE TARGET, because │
16
+ * │ a backend response about a historical run (evidence, screenshots, an │
17
+ * │ execution's stored targetUrl) can still carry one of those hostnames. It │
18
+ * │ costs one entry in a list; removing it means those URLs leak to the caller │
19
+ * │ verbatim. Retire it when no stored run references it any more — bead │
20
+ * │ debugg_ai_mcp-xkoh.6.6, which also drops the *.ngrok.debugg.ai DNS. │
21
+ * │ Locked by __tests__/utils/tunnelDomains.test.ts. │
22
+ * └────────────────────────────────────────────────────────────────────────────┘
23
+ *
15
24
  * Imports NOTHING, deliberately: utils/urlParser.ts, utils/localReachability.ts
16
- * and services/ngrok/tunnelManager.ts all depend on it, and it must never be
25
+ * and services/tunnel/tunnelManager.ts all depend on it, and it must never be
17
26
  * the module that creates a cycle.
18
27
  */
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]));
28
+ const LIVE_DOMAIN = 'tunnel.debugg.ai';
29
+ /** Rewrite-only. See the box above before touching this. */
30
+ const LEGACY_DOMAINS = ['ngrok.debugg.ai'];
31
+ const BUILT_IN_DOMAINS = Object.freeze([LIVE_DOMAIN, ...LEGACY_DOMAINS]);
32
+ const domains = new Set(BUILT_IN_DOMAINS);
24
33
  /**
25
34
  * A tunnel domain must be a lowercase DNS name of at least THREE labels.
26
35
  *
@@ -44,25 +53,25 @@ export function isValidTunnelDomain(domain) {
44
53
  * every provision. Returns false for a domain that fails validation, so a
45
54
  * caller can turn that into an error rather than silently trusting it.
46
55
  */
47
- export function registerTunnelDomain(domain, transport) {
56
+ export function registerTunnelDomain(domain) {
48
57
  if (!isValidTunnelDomain(domain))
49
58
  return false;
50
- domains.set(domain, transport);
59
+ domains.add(domain);
51
60
  return true;
52
61
  }
53
62
  /** Every known domain, built-in and registered. */
54
63
  export function knownTunnelDomains() {
55
- return [...domains.entries()].map(([domain, transport]) => ({ domain, transport }));
64
+ return [...domains].map((domain) => ({ domain }));
56
65
  }
57
66
  /** The known domain a host sits under, or undefined. Accepts a host or a full URL. */
58
67
  export function tunnelDomainFor(hostOrUrl) {
59
68
  const host = hostOf(hostOrUrl);
60
69
  if (!host)
61
70
  return undefined;
62
- for (const [domain, transport] of domains) {
71
+ for (const domain of domains) {
63
72
  // A tunnel host is always `<label>.<domain>` — never the bare domain.
64
73
  if (host.length > domain.length + 1 && host.endsWith(`.${domain}`)) {
65
- return { domain, transport };
74
+ return { domain };
66
75
  }
67
76
  }
68
77
  return undefined;
@@ -85,7 +94,7 @@ export function extractTunnelIdFromHost(hostOrUrl) {
85
94
  * short lists and the callers are not hot loops.
86
95
  */
87
96
  export function tunnelUrlPattern() {
88
- const alternatives = [...domains.keys()].map(escapeRegExp).join('|');
97
+ const alternatives = [...domains].map(escapeRegExp).join('|');
89
98
  return new RegExp(`https?:\\/\\/[^\\s/"']+\\.(?:${alternatives})`, 'g');
90
99
  }
91
100
  function hostOf(hostOrUrl) {
@@ -108,6 +117,6 @@ function escapeRegExp(value) {
108
117
  /** Test hook: drop every runtime-registered domain, keeping the built-ins. */
109
118
  export function _resetTunnelDomainsForTests() {
110
119
  domains.clear();
111
- for (const { domain, transport } of BUILT_IN_DOMAINS)
112
- domains.set(domain, transport);
120
+ for (const domain of BUILT_IN_DOMAINS)
121
+ domains.add(domain);
113
122
  }
@@ -92,10 +92,9 @@ export function isLocalhostUrl(urlString) {
92
92
  * Used to sanitize backend responses that contain internal tunnel URLs before
93
93
  * returning them to callers who only know the original localhost address.
94
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.
95
+ * Covers EVERY known tunnel domain (utils/tunnelDomains.ts), including the
96
+ * retired `ngrok.debugg.ai` one a historical run's stored URLs still carry: a
97
+ * domain this does not know is a hostname that leaks to the caller.
99
98
  */
100
99
  export function replaceTunnelUrls(value, localhostOrigin) {
101
100
  if (typeof value === 'string') {
@@ -116,7 +115,7 @@ export function replaceTunnelUrls(value, localhostOrigin) {
116
115
  /**
117
116
  * Re-point a REUSED tunnel at the current caller's path (bead zmc9).
118
117
  *
119
- * A tunnel is origin-scoped: its `tunnelUrl` (scheme://host from ngrok) forwards
118
+ * A tunnel is origin-scoped: its `tunnelUrl` (scheme://host from the transport) forwards
120
119
  * every path. The path is request-scoped. On reuse we must compose the reused
121
120
  * tunnel's ORIGIN with THIS request's path/search/hash — never replay the
122
121
  * path-bearing `publicUrl` baked in by whichever call created the tunnel.
@@ -135,10 +134,43 @@ export function retargetTunnelUrl(tunnelOrigin, requestedUrl) {
135
134
  return tunnelOrigin;
136
135
  }
137
136
  }
137
+ /**
138
+ * Rewrite every localhost / 127.0.0.1 URL inside FREE TEXT onto the tunnel.
139
+ *
140
+ * The goal text is read by a browser running in OUR cloud, not on the user's
141
+ * machine, so "go to http://localhost:3017" tells it to dial its own loopback.
142
+ * It gets connection refused and the run is recorded as the app failing, while
143
+ * the tunnel was working the whole time (15 of 431 judged prod runs in 30 days,
144
+ * platform-98fv.16).
145
+ *
146
+ * Deliberately narrow:
147
+ * - only a real origin match (scheme + host + optional port) is touched, so
148
+ * a word like "localhostname" survives;
149
+ * - path, query and fragment are preserved, because the goal usually depends
150
+ * on them;
151
+ * - with no tunnel URL, the text is returned UNCHANGED. Inventing a
152
+ * destination would be worse than leaving a wrong one visible.
153
+ */
154
+ export function rewriteLocalhostInText(text, tunnelUrl) {
155
+ if (!text || !tunnelUrl)
156
+ return text;
157
+ let origin;
158
+ try {
159
+ const u = new URL(tunnelUrl);
160
+ origin = `${u.protocol}//${u.host}`;
161
+ }
162
+ catch {
163
+ return text;
164
+ }
165
+ // scheme://(localhost|127.0.0.1)[:port] — the trailing boundary stops
166
+ // "localhostname" matching, while still allowing a path/query/fragment.
167
+ const LOCAL_ORIGIN = /\bhttps?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(?=[/?#\s]|$)/gi;
168
+ return text.replace(LOCAL_ORIGIN, origin);
169
+ }
138
170
  /**
139
171
  * Generate a tunneled URL for a localhost URL
140
172
  */
141
- export function generateTunnelUrl(originalUrl, tunnelId, tunnelDomain = 'ngrok.debugg.ai') {
173
+ export function generateTunnelUrl(originalUrl, tunnelId, tunnelDomain) {
142
174
  try {
143
175
  const parsed = parseUrl(originalUrl);
144
176
  if (!parsed.isLocalhost) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@debugg-ai/debugg-ai-mcp",
3
- "version": "4.4.1",
3
+ "version": "6.0.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",
@@ -18,7 +18,7 @@
18
18
  ],
19
19
  "scripts": {
20
20
  "lint": "eslint .",
21
- "build": "tsc && shx chmod +x dist/index.js",
21
+ "build": "shx rm -rf dist && tsc && shx chmod +x dist/index.js",
22
22
  "prepare": "npm run build",
23
23
  "watch": "tsc --watch",
24
24
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
@@ -61,7 +61,6 @@
61
61
  "@radically-straightforward/caddy": "^2.0.12",
62
62
  "axios": "^1.9.0",
63
63
  "mkdirp": "^3.0.1",
64
- "ngrok": "^5.0.0-beta.2",
65
64
  "posthog-node": "^5.26.0",
66
65
  "uuid": "^11.1.0",
67
66
  "winston": "^3.15.0",
@@ -1,3 +0,0 @@
1
- export function isError(error) {
2
- return error instanceof Error;
3
- }
@@ -1,210 +0,0 @@
1
- /**
2
- * Local ngrok agent inspector (bead lc62).
3
- *
4
- * Every ngrok agent process serves a local inspection API on 127.0.0.1:4040,
5
- * incrementing to 4041, 4042, ... when the port is taken. `GET /api/tunnels`
6
- * answers with the tunnels THAT AGENT currently has open:
7
- *
8
- * {"tunnels":[{"public_url":"https://x.ngrok.debugg.ai",
9
- * "config":{"addr":"http://127.0.0.1:3011"}}],"uri":"/api/tunnels"}
10
- *
11
- * This is the only free, honest source of tunnel liveness we have. It is
12
- * loopback: no DNS, no TLS, no ngrok edge, no undici, and — critically — no
13
- * request to the tunnel's PUBLIC url. Probing the public url for a reuse
14
- * decision is the k6yq / z15n / kmzb failure class: the edge answers undici
15
- * over HTTP/2 and the response is lost to a concurrent GOAWAY, so a live tunnel
16
- * reads as dead and gets replaced for two billed hours.
17
- *
18
- * ── The safety property, and why it is structural rather than a policy ──
19
- *
20
- * A tunnel MISSING from this API is not proof it is gone: the agent that owns
21
- * it may be on a port we did not scan, or may not be answering. Only a
22
- * REACHABLE agent that does not list a tunnel is evidence, and even that is
23
- * scoped to that one agent.
24
- *
25
- * Rather than encode that asymmetry as a tri-state ("live" / "gone" /
26
- * "unknown") that some future caller could mishandle, the inspector returns
27
- * only what it POSITIVELY OBSERVED, and TunnelManager uses it in an
28
- * ADD-ONLY way: reconciliation can create or refresh a registry entry, never
29
- * delete one. An unreachable agent therefore contributes an empty list and
30
- * changes nothing versus today's behaviour, and no failure of this module can
31
- * ever cost a re-provision. That is a guarantee about the shape of the data
32
- * flow, not a rule someone has to remember.
33
- *
34
- * Adoption is filtered to `*.ngrok.debugg.ai`. A developer's own unrelated
35
- * ngrok tunnels share this agent API and must never be touched by us.
36
- */
37
- import { request as httpRequest } from 'node:http';
38
- import { Logger } from '../../utils/logger.js';
39
- import { getFaultModeFromEnv } from './tunnelFaultInjection.js';
40
- const logger = new Logger({ module: 'ngrokAgentInspector' });
41
- /** Only tunnels on this suffix are ours to adopt. */
42
- const OWNED_TUNNEL_SUFFIX = '.ngrok.debugg.ai';
43
- /** ngrok's web-inspector base port, and how far it walks when 4040 is busy. */
44
- const FIRST_INSPECTOR_PORT = 4040;
45
- const INSPECTOR_PORT_COUNT = 10;
46
- /**
47
- * Parse one agent's `/api/tunnels` body into the tunnels we are allowed to own.
48
- *
49
- * Tolerant by construction — every unexpected shape drops the entry rather than
50
- * throwing, because a parse failure must degrade to "learned nothing".
51
- *
52
- * ngrok reports the same tunnel more than once when it exposes several protos,
53
- * so results are de-duplicated by tunnelId; first hostname wins.
54
- */
55
- export function parseAgentTunnels(body) {
56
- let parsed;
57
- try {
58
- parsed = JSON.parse(body);
59
- }
60
- catch {
61
- return [];
62
- }
63
- const raw = parsed?.tunnels;
64
- if (!Array.isArray(raw))
65
- return [];
66
- const seen = new Set();
67
- const out = [];
68
- for (const item of raw) {
69
- const publicUrl = item?.public_url;
70
- const addr = item?.config?.addr;
71
- if (typeof publicUrl !== 'string' || typeof addr !== 'string')
72
- continue;
73
- const tunnelId = tunnelIdFromPublicUrl(publicUrl);
74
- if (!tunnelId || seen.has(tunnelId))
75
- continue;
76
- const port = portFromAgentAddr(addr);
77
- if (port === undefined)
78
- continue;
79
- seen.add(tunnelId);
80
- out.push({ tunnelId, publicUrl, port });
81
- }
82
- return out;
83
- }
84
- /**
85
- * Subdomain of a `*.ngrok.debugg.ai` public url, or undefined for anything
86
- * else — including a developer's personal ngrok tunnels, which share this
87
- * agent API and are none of our business.
88
- */
89
- function tunnelIdFromPublicUrl(publicUrl) {
90
- let host;
91
- try {
92
- host = new URL(publicUrl).hostname.toLowerCase();
93
- }
94
- catch {
95
- return undefined;
96
- }
97
- if (!host.endsWith(OWNED_TUNNEL_SUFFIX))
98
- return undefined;
99
- const sub = host.slice(0, -OWNED_TUNNEL_SUFFIX.length);
100
- // A bare or multi-label subdomain is not a tunnelId we minted.
101
- return sub && !sub.includes('.') ? sub : undefined;
102
- }
103
- /**
104
- * Local port out of an agent `config.addr`, which ngrok reports in several
105
- * shapes depending on how connect() was called: `http://127.0.0.1:3011`,
106
- * `https://localhost:3443`, or a bare `localhost:3011`.
107
- */
108
- export function portFromAgentAddr(addr) {
109
- const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(addr) ? addr : `tcp://${addr}`;
110
- try {
111
- const port = new URL(withScheme).port;
112
- if (port) {
113
- const n = Number(port);
114
- return Number.isInteger(n) && n > 0 && n <= 65535 ? n : undefined;
115
- }
116
- }
117
- catch {
118
- /* fall through */
119
- }
120
- return undefined;
121
- }
122
- /**
123
- * GET one agent's /api/tunnels over loopback HTTP/1.1.
124
- *
125
- * `agent: false` so the socket is closed rather than pooled — this runs once
126
- * per process and must not leave a keep-alive handle behind.
127
- */
128
- function fetchAgentTunnelsOverHttp(port, timeoutMs) {
129
- return new Promise((resolve) => {
130
- let settled = false;
131
- const done = (value) => {
132
- if (settled)
133
- return;
134
- settled = true;
135
- resolve(value);
136
- };
137
- const req = httpRequest({ host: '127.0.0.1', port, path: '/api/tunnels', method: 'GET', agent: false, timeout: timeoutMs }, (res) => {
138
- if (res.statusCode !== 200) {
139
- res.resume();
140
- return done(undefined);
141
- }
142
- let body = '';
143
- res.setEncoding('utf8');
144
- // 256KB ceiling: something answering on 4040 that is not an ngrok agent
145
- // must not be able to stream us out of memory.
146
- res.on('data', (chunk) => {
147
- if (body.length < 262144)
148
- body += chunk;
149
- });
150
- res.on('end', () => done(body));
151
- res.on('error', () => done(undefined));
152
- });
153
- req.on('timeout', () => { req.destroy(); done(undefined); });
154
- // Nothing is listening on most of these ports; ECONNREFUSED is the norm.
155
- req.on('error', () => done(undefined));
156
- req.end();
157
- });
158
- }
159
- /**
160
- * Scans the local agents. Ports are probed in parallel — nearly all of them
161
- * refuse instantly, so the wall cost is one timeout at worst.
162
- */
163
- export function createLocalAgentInspector(opts = {}) {
164
- const ports = opts.ports ?? Array.from({ length: INSPECTOR_PORT_COUNT }, (_, i) => FIRST_INSPECTOR_PORT + i);
165
- const timeoutMs = opts.timeoutMs ?? 500;
166
- const fetchOne = opts.fetchAgentTunnels ?? fetchAgentTunnelsOverHttp;
167
- return {
168
- async listLiveTunnels() {
169
- const faults = opts.faultMode !== undefined ? opts.faultMode : getFaultModeFromEnv();
170
- if (faults?.inspectorUnreachable) {
171
- logger.debug('[fault-inject] ngrok agent inspector forced unreachable');
172
- return [];
173
- }
174
- if (faults?.inspectorAdoptPort !== undefined) {
175
- const port = faults.inspectorAdoptPort;
176
- logger.debug(`[fault-inject] ngrok agent inspector reporting a synthetic tunnel on port ${port}`);
177
- return [{
178
- tunnelId: `fault-adopt-${port}`,
179
- publicUrl: `https://fault-adopt-${port}${OWNED_TUNNEL_SUFFIX}`,
180
- port,
181
- }];
182
- }
183
- const bodies = await Promise.all(ports.map((port) => fetchOne(port, timeoutMs).catch(() => undefined)));
184
- const seen = new Set();
185
- const found = [];
186
- for (const body of bodies) {
187
- if (!body)
188
- continue;
189
- for (const tunnel of parseAgentTunnels(body)) {
190
- if (seen.has(tunnel.tunnelId))
191
- continue;
192
- seen.add(tunnel.tunnelId);
193
- found.push(tunnel);
194
- }
195
- }
196
- return found;
197
- },
198
- };
199
- }
200
- /** Learns nothing, ever. The default under NODE_ENV=test. */
201
- export const noopInspector = {
202
- listLiveTunnels: async () => [],
203
- };
204
- /**
205
- * Mirrors getDefaultRegistry(): tests get the inert inspector so no unit test
206
- * can reach the network, production gets the real loopback scan.
207
- */
208
- export function getDefaultInspector() {
209
- return process.env.NODE_ENV === 'test' ? noopInspector : createLocalAgentInspector();
210
- }
@@ -1,49 +0,0 @@
1
- /**
2
- * ngrok agent session bootstrap (bead pqgj).
3
- *
4
- * ngrok's connect() calls its internal getProcess() and then IMMEDIATELY asks
5
- * the agent to open a tunnel. But getProcess() resolves as soon as the agent
6
- * prints its local API address, which — measured live against agent v3.38.0 —
7
- * is ~293ms BEFORE the client session is established:
8
- *
9
- * [ 110ms] msg="starting web service" addr=127.0.0.1:4043 <- getProcess resolves
10
- * [ 403ms] msg="client session established" <- ~293ms later
11
- *
12
- * Tunnelling inside that window returns 503 "a successful ngrok tunnel session
13
- * has not yet been established". That 503 is retriable and ngrok retries it
14
- * internally — but connectRetry() pins opts.name on the first call and reuses
15
- * it, so its own retry collides with the tunnel record the 503 left behind:
16
- * 400 "invalid tunnel configuration — tunnel already exists". 400 is not
17
- * retriable, so it surfaces to us. That is bead pqgj's 100% attempt-1 failure.
18
- *
19
- * Fix: start the agent and wait for its session BEFORE anyone tunnels.
20
- * getProcess() memoizes on a module-level promise, so ngrok's own connect()
21
- * then reuses the very agent we started and never re-spawns it.
22
- *
23
- * getProcess is not in ngrok's package export map ({".": "./index.js"}), hence
24
- * the resolved deep require. It is isolated in this module so the reach into
25
- * another package's internals is explicit, greppable, and mockable — and so a
26
- * version bump that moves it fails HERE, loudly, instead of silently spawning
27
- * something unexpected. Callers treat a throw as "no pre-warm available" and
28
- * fall back to their connect retry ladder.
29
- */
30
- import { createRequire } from 'node:module';
31
- import { dirname, join } from 'node:path';
32
- /**
33
- * Spawn (or reuse) the ngrok agent, wiring the session callbacks.
34
- *
35
- * Resolves once the agent's local API is up — NOT once the session is live.
36
- * Callers await the session via `onStatusChange('connected')`.
37
- *
38
- * @throws if ngrok's internals cannot be reached (version bump, stubbed module).
39
- */
40
- export const startAgentSession = async (opts) => {
41
- const require = createRequire(import.meta.url);
42
- const ngrokEntry = require.resolve('ngrok');
43
- const processModulePath = join(dirname(ngrokEntry), 'src', 'process.js');
44
- const { getProcess } = require(processModulePath);
45
- if (typeof getProcess !== 'function') {
46
- throw new Error(`ngrok getProcess() not found at ${processModulePath}`);
47
- }
48
- await getProcess(opts);
49
- };
@@ -1 +0,0 @@
1
- export {};