@debugg-ai/debugg-ai-mcp 3.9.1 → 3.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,9 @@
14
14
  * misconfigured ngrok, etc.)
15
15
  */
16
16
  import { createConnection } from 'node:net';
17
+ import { request as httpRequest } from 'node:http';
18
+ import { request as httpsRequest } from 'node:https';
19
+ import { Readable } from 'node:stream';
17
20
  export async function probeLocalPort(port, opts = {}) {
18
21
  const host = opts.host ?? '127.0.0.1';
19
22
  const timeoutMs = opts.timeoutMs ?? 1500;
@@ -72,9 +75,134 @@ const TRANSIENT_CAUSE_CODES = new Set([
72
75
  'EAI_AGAIN', // DNS: temporary resolver failure
73
76
  'ECONNRESET', // edge dropped the connection mid-handshake
74
77
  'EPIPE',
75
- 'UND_ERR_SOCKET', // undici socket error this is the GOAWAY case
78
+ // undici codes. Since bead kmzb the default probe transport is node:https,
79
+ // which reports the OS codes above instead — these stay for an injected
80
+ // fetchFn, and as the record of what the HTTP/2 path used to produce.
81
+ 'UND_ERR_SOCKET',
76
82
  'UND_ERR_CONNECT_TIMEOUT',
77
83
  ]);
84
+ /**
85
+ * ngrok codes meaning "the edge does not route this hostname".
86
+ *
87
+ * Kept here deliberately separate from tunnelDisposition's ENDPOINT_GONE
88
+ * allowlist even though they currently hold the same code: that list decides
89
+ * whether a verdict may DESTROY a tunnel, this one decides whether a verdict is
90
+ * worth double-checking first. A code could reasonably be on one and not the
91
+ * other, and coupling them would make this module depend on policy it has no
92
+ * business knowing.
93
+ */
94
+ const ENDPOINT_NOT_FOUND_CODES = new Set(['ERR_NGROK_3200']);
95
+ // ─ HTTP/1.1 probe transport (bead kmzb) ──────────────────────────────────────
96
+ /**
97
+ * `fetch`-shaped GET that speaks HTTP/1.1, because the global fetch cannot.
98
+ *
99
+ * Measured against the ngrok edge on 2026-07-27, Node v26:
100
+ *
101
+ * ALPN offer [h2, http/1.1] → edge selects h2
102
+ * raw HTTP/2 GET of a hostname ngrok no longer routes
103
+ * → 404 with ERR_NGROK_3200 in the body, AND a GOAWAY (code 0, NO_ERROR)
104
+ * on the same connection
105
+ * undici (global fetch) over that h2 connection
106
+ * → TypeError: fetch failed / UND_ERR_SOCKET, 3/3 — the response is lost
107
+ * to the concurrent GOAWAY and never surfaces
108
+ * node:https (HTTP/1.1)
109
+ * → 404 + ERR_NGROK_3200, 3/3
110
+ *
111
+ * So the edge was answering us the whole time and undici was dropping the
112
+ * answer. That is why `ngrokErrorCode` had never once been populated in
113
+ * production, which in turn made tunnelDisposition's ENDPOINT_GONE allowlist
114
+ * unreachable: every probe failure looked like a transient network error, and
115
+ * a genuinely dead endpoint could never be told apart from a flake. Speaking
116
+ * HTTP/1.1 makes the distinction observable again and re-activates that
117
+ * allowlist — so ERR_NGROK_3200 can once more evict a tunnel that is provably
118
+ * gone, while everything short of proof still keeps the tunnel we are paying
119
+ * for. It also removes the h2 GOAWAY that bead k6yq's retry ladder existed to
120
+ * paper over, leaving the ladder as a genuine safety net rather than a
121
+ * load-bearing workaround.
122
+ *
123
+ * Node's fetch offers no way to disable h2, hence a transport rather than an
124
+ * option. `http.ClientRequest` — which `https.request` returns — implements
125
+ * HTTP/1.x only, so this cannot regress into h2 no matter what a future Node
126
+ * negotiates by default. The seam is deliberately `typeof fetch` so every
127
+ * caller and test that injects `fetchFn` is unaffected.
128
+ */
129
+ export const http1Fetch = async (input, init) => {
130
+ const url = new URL(typeof input === 'string' ? input : input.url ?? String(input));
131
+ const isPlainHttp = url.protocol === 'http:';
132
+ const signal = init?.signal ?? undefined;
133
+ const options = {
134
+ protocol: url.protocol,
135
+ host: url.hostname,
136
+ port: url.port || (isPlainHttp ? 80 : 443),
137
+ path: `${url.pathname}${url.search}`,
138
+ method: (init?.method ?? 'GET').toUpperCase(),
139
+ headers: init?.headers ?? {},
140
+ // One-shot probe — do not leave a pooled keep-alive socket behind.
141
+ agent: false,
142
+ };
143
+ return new Promise((resolve, reject) => {
144
+ let settled = false;
145
+ const fail = (err) => {
146
+ if (settled)
147
+ return;
148
+ settled = true;
149
+ reject(err);
150
+ };
151
+ const onResponse = (res) => {
152
+ if (settled)
153
+ return;
154
+ settled = true;
155
+ const status = res.statusCode ?? 0;
156
+ // Response rejects a body on these statuses; they have none anyway.
157
+ const bodyless = status === 101 || status === 204 || status === 205 || status === 304;
158
+ if (bodyless)
159
+ res.resume();
160
+ resolve(new Response(bodyless ? null : Readable.toWeb(res), {
161
+ status,
162
+ headers: headersFrom(res.headers),
163
+ }));
164
+ };
165
+ const req = isPlainHttp
166
+ ? httpRequest(options, onResponse)
167
+ : httpsRequest(options, onResponse);
168
+ if (signal) {
169
+ if (signal.aborted) {
170
+ req.destroy();
171
+ return fail(abortError());
172
+ }
173
+ signal.addEventListener('abort', () => {
174
+ req.destroy();
175
+ fail(abortError());
176
+ }, { once: true });
177
+ }
178
+ req.on('error', fail);
179
+ req.end();
180
+ });
181
+ };
182
+ /** Matches what an aborted fetch throws, so probeOnce's TIMEOUT arm still fires. */
183
+ function abortError() {
184
+ const err = new Error('The operation was aborted.');
185
+ err.name = 'AbortError';
186
+ return err;
187
+ }
188
+ /** node:http header bag → Headers, skipping the pseudo/array oddities. */
189
+ function headersFrom(raw) {
190
+ const headers = new Headers();
191
+ for (const [key, value] of Object.entries(raw)) {
192
+ if (value === undefined)
193
+ continue;
194
+ for (const v of Array.isArray(value) ? value : [value]) {
195
+ try {
196
+ headers.append(key, v);
197
+ }
198
+ catch {
199
+ // A header Headers refuses (invalid name from a hostile server) is not
200
+ // worth failing a health probe over.
201
+ }
202
+ }
203
+ }
204
+ return headers;
205
+ }
78
206
  /** undici hides the real error behind `TypeError: fetch failed` — dig it out. */
79
207
  function errorCodeOf(err) {
80
208
  return err?.cause?.code ?? err?.code;
@@ -113,7 +241,8 @@ export async function probeTunnelHealth(tunnelUrl, opts = {}) {
113
241
  }
114
242
  async function probeOnce(tunnelUrl, opts, started) {
115
243
  const timeoutMs = opts.timeoutMs ?? 5000;
116
- const fetchImpl = opts.fetchFn ?? fetch;
244
+ // Bead kmzb: HTTP/1.1, not the global fetch — see http1Fetch.
245
+ const fetchImpl = opts.fetchFn ?? http1Fetch;
117
246
  const controller = new AbortController();
118
247
  const timer = setTimeout(() => controller.abort(), timeoutMs);
119
248
  try {
@@ -129,18 +258,31 @@ async function probeOnce(tunnelUrl, opts, started) {
129
258
  // ngrok error pages are small; a full user app body is a waste.
130
259
  const bodyText = await readCapped(res, 4096);
131
260
  const ngrokErr = extractNgrokErrorCode(bodyText);
132
- // 502/504 + ngrok error marker ngrok couldn't reach our server.
133
- // A RESPONSE is proof the edge is up: never retried, so bead 4bui's
261
+ // An ngrok error page. A RESPONSE is proof the edge is up, so these are
262
+ // returned as-is rather than retried into a false pass, and bead 4bui's
134
263
  // marker-driven reclassification keeps seeing the real verdict.
264
+ //
265
+ // One exception, and it exists only because bead kmzb made these codes
266
+ // reachable at all: ENDPOINT_NOT_FOUND is the single verdict that
267
+ // AUTHORISES A TEARDOWN (tunnelDisposition's allowlist), and the ngrok
268
+ // agent passes through a short window where it is briefly true — while a
269
+ // dropped session reconnects and re-announces the same hostname. Acting on
270
+ // one sample would kill a tunnel that was about to come back, at a cost of
271
+ // two billed hours. So this code alone is confirmed across the retry
272
+ // ladder: a reconnect resolves inside it, a genuinely deleted endpoint
273
+ // answers the same way every time and is still reported as gone.
135
274
  if (ngrokErr) {
275
+ const notFound = ENDPOINT_NOT_FOUND_CODES.has(ngrokErr);
136
276
  return {
137
- retryable: false,
277
+ retryable: notFound,
138
278
  result: {
139
279
  healthy: false,
140
280
  status: res.status,
141
281
  code: 'NGROK_ERROR',
142
282
  ngrokErrorCode: ngrokErr,
143
- detail: `ngrok returned ${ngrokErr} — tunnel established but traffic could not reach dev server`,
283
+ detail: notFound
284
+ ? `ngrok returned ${ngrokErr} — the edge no longer routes this tunnel hostname`
285
+ : `ngrok returned ${ngrokErr} — tunnel established but traffic could not reach dev server`,
144
286
  elapsedMs: Date.now() - started,
145
287
  },
146
288
  };
@@ -0,0 +1,128 @@
1
+ /**
2
+ * What to do with a tunnel whose health probe just failed.
3
+ *
4
+ * ngrok bills a MINIMUM OF ONE HOUR PER TUNNEL, so tearing one down and standing
5
+ * a replacement up costs TWO billed hours. A teardown therefore has to be backed
6
+ * by proof that the tunnel is already gone — not by a probe result that a
7
+ * transient edge flake produces just as readily as a real death.
8
+ *
9
+ * All four tool handlers probe tunnel health before handing the URL to the remote
10
+ * browser, and all four used to gate eviction on `health.ngrokErrorCode` being
11
+ * set AT ALL, falling back to stopTunnel() otherwise. Both halves were wrong:
12
+ *
13
+ * - `ngrokErrorCode` is not a verdict. ERR_NGROK_8012 means the tunnel is ALIVE
14
+ * and its upstream refused the connection — the tunnel is literally what
15
+ * served us that error. Evicting on it orphans a live, billing tunnel and
16
+ * makes the next call provision a replacement: two billed hours spent on a
17
+ * dev server that was the actual problem.
18
+ * - the stopTunnel() fallback tore down an OWNED tunnel on ANY probe failure,
19
+ * including the transient HTTP/2 GOAWAY the ngrok edge sends to undici
20
+ * (bead k6yq — measured at roughly 1 run in 5 before the retry ladder landed,
21
+ * and still reachable whenever that ladder exhausts).
22
+ *
23
+ * Hence an explicit allowlist of codes that prove the ENDPOINT ITSELF is gone.
24
+ * Everything else leaves both the tunnel and the shared registry entry completely
25
+ * untouched and just reports TunnelTrafficBlocked: the user fixes their dev
26
+ * server and the next call reuses the same tunnel for free.
27
+ *
28
+ * Bead kmzb, confirmed live on 2026-07-27: probeTunnelHealth cannot currently
29
+ * produce ANY ngrokErrorCode against this edge. `curl` and a raw node:https
30
+ * request both get `404 + ERR_NGROK_3200` from a hostname ngrok no longer routes,
31
+ * but undici's fetch negotiates HTTP/2 and the edge answers with a GOAWAY, which
32
+ * surfaces as UND_ERR_SOCKET / NETWORK_ERROR with no code at all. So in practice
33
+ * every probe failure takes the leave-it-alone branch today. The allowlist is the
34
+ * policy that governs the moment a code CAN be produced — whether that is kmzb
35
+ * forcing HTTP/1.1 for the probe, or a caller passing the marker recorded by a
36
+ * run's own evidence (bead 4bui, the one live-confirmed source of ERR_NGROK_*).
37
+ */
38
+ import { tunnelManager } from '../services/ngrok/tunnelManager.js';
39
+ import { extractLocalhostPort } from './urlParser.js';
40
+ import { Logger } from './logger.js';
41
+ const logger = new Logger({ module: 'tunnelDisposition' });
42
+ /**
43
+ * ngrok error codes that PROVE the endpoint no longer exists, so evicting it
44
+ * costs nothing and re-borrowing it costs everyone a failed run (bead k34o).
45
+ *
46
+ * Inclusion criterion — the code must be served BY THE EDGE ABOUT A HOSTNAME IT
47
+ * NO LONGER ROUTES, i.e. it is impossible to receive it from a tunnel that is
48
+ * still up. Anything describing the upstream, the agent, or the connection is a
49
+ * live tunnel reporting on something else, and must NOT be here.
50
+ *
51
+ * Verify a candidate before adding it — the check is free and creates no tunnel:
52
+ * curl -s https://<random-uuid>.ngrok.debugg.ai/ | grep -o 'ERR_NGROK_[0-9]*'
53
+ * (use curl or node:https, NOT fetch — see the kmzb note above).
54
+ *
55
+ * ERR_NGROK_3200 — "not found". Verified live 2026-07-27: a GET to a
56
+ * nonexistent *.ngrok.debugg.ai host returns 404 with this code in the body.
57
+ * The endpoint is definitively gone; nothing is billing for it.
58
+ *
59
+ * Deliberately EXCLUDED, and the sharpest case of all:
60
+ *
61
+ * ERR_NGROK_8012 — the agent could not dial the upstream (connection refused).
62
+ * The TUNNEL IS ALIVE; it is the thing that generated the error page. Evicting
63
+ * on 8012 orphans a paid-for tunnel and buys a duplicate — two billed hours —
64
+ * to work around a dev server that is down, bound to the wrong interface, or
65
+ * still starting up. Leave it be; it will serve the very next request once the
66
+ * user's server is back.
67
+ *
68
+ * This set is pinned by a test. Adding a code has to be a deliberate act with
69
+ * evidence behind it, not a silent default to teardown.
70
+ *
71
+ * The frozen ARRAY is the source of truth, not a frozen Set: Object.freeze does
72
+ * not make a Set immutable — its entries live in internal slots, so `.add()` on
73
+ * a "frozen" Set still succeeds silently. Freezing the array is a real runtime
74
+ * guarantee; the lookup Set is derived from it and kept private.
75
+ */
76
+ const ENDPOINT_GONE_CODES = Object.freeze(['ERR_NGROK_3200']);
77
+ const ENDPOINT_GONE_LOOKUP = new Set(ENDPOINT_GONE_CODES);
78
+ /** The allowlist, as an immutable list. Read-only by construction. */
79
+ export const ENDPOINT_GONE_NGROK_CODES = ENDPOINT_GONE_CODES;
80
+ /**
81
+ * True only when the code proves the endpoint is gone. Unset / unknown codes are
82
+ * NOT proof of anything, so they answer false: the default is always to keep the
83
+ * tunnel we are already paying for.
84
+ */
85
+ export function isEndpointGone(ngrokErrorCode) {
86
+ return !!ngrokErrorCode && ENDPOINT_GONE_LOOKUP.has(ngrokErrorCode);
87
+ }
88
+ /**
89
+ * Decide — once, in one place — what an unhealthy tunnel health probe does to the
90
+ * tunnel. Called by every handler that probes, so the policy cannot drift between
91
+ * them (run_test_suite had already drifted: it evicted on every failure and never
92
+ * got bead k34o's shared-registry eviction at all).
93
+ *
94
+ * Endpoint proven gone → markTunnelDead: for an owned tunnel that disconnects and
95
+ * revokes the key; for a BORROWED one it also evicts the
96
+ * shared registry entry, which plain stopTunnel leaves
97
+ * behind for every other session to re-borrow (bead k34o).
98
+ * Anything else → nothing at all. The caller still returns
99
+ * TunnelTrafficBlocked, so the user is told; we simply do
100
+ * not spend two billed hours acting on a verdict this
101
+ * probe cannot actually deliver.
102
+ *
103
+ * Never throws and never awaits the eviction: a cleanup decision must not be able
104
+ * to fail or slow down the error response the caller is about to return.
105
+ */
106
+ export function disposeUnhealthyTunnel(args) {
107
+ const { health, tunnelId, originalUrl } = args;
108
+ if (!tunnelId)
109
+ return;
110
+ if (!isEndpointGone(health.ngrokErrorCode)) {
111
+ logger.info(`Tunnel ${tunnelId} failed its health probe (${health.code}${health.ngrokErrorCode ? ` ${health.ngrokErrorCode}` : ''}) ` +
112
+ 'but nothing proves the endpoint is gone — keeping it. Tearing down a live tunnel costs two billed hours ' +
113
+ '(1-hour minimum down, another up) and the next call reuses this one for free.');
114
+ return;
115
+ }
116
+ const port = extractLocalhostPort(originalUrl);
117
+ if (typeof port !== 'number') {
118
+ // markTunnelDead is keyed by port. Without one we cannot evict the shared
119
+ // entry safely, and a blind stopTunnel is exactly the teardown this module
120
+ // exists to prevent — so keep the tunnel and say why.
121
+ logger.warn(`Tunnel ${tunnelId} reported ${health.ngrokErrorCode} but no port could be parsed from ${originalUrl} — ` +
122
+ 'leaving it in place rather than risking a teardown of a live tunnel.');
123
+ return;
124
+ }
125
+ logger.warn(`Tunnel ${tunnelId} on port ${port} reported ${health.ngrokErrorCode} — the endpoint is gone, evicting it ` +
126
+ 'so no session re-borrows the corpse (bead k34o).');
127
+ tunnelManager.markTunnelDead(port, tunnelId).catch((err) => logger.warn(`Failed to evict dead tunnel ${tunnelId}: ${err}`));
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@debugg-ai/debugg-ai-mcp",
3
- "version": "3.9.1",
3
+ "version": "3.9.3",
4
4
  "description": "Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.",
5
5
  "type": "module",
6
6
  "bin": {