@debugg-ai/debugg-ai-mcp 3.9.2 → 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.
@@ -0,0 +1,210 @@
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
+ }
@@ -6,20 +6,27 @@
6
6
  * - DEBUGG_TUNNEL_FAULT_MODE env var explicitly set
7
7
  *
8
8
  * Modes (comma-separated, parseable by parseFaultMode):
9
- * fail-connect-N:<count> — fail the first <count> ngrok.connect() attempts
9
+ * fail-connect-N:<count> — fail the first <count> ngrok.connect() attempts
10
10
  * empty-url-N:<count> — return empty URL from first <count> connect() attempts
11
11
  * delay-connect:<ms> — sleep <ms> before each connect() call
12
+ * inspector-unreachable:1 — the local ngrok agent inspector observes nothing
13
+ * (bead lc62: proves reconciliation is add-only, i.e.
14
+ * a blind inspector cannot cost a re-provision)
15
+ * inspector-adopt:<port> — the inspector reports one synthetic live tunnel on
16
+ * <port>, exercising re-adoption end to end without
17
+ * provisioning a real, billable tunnel
12
18
  *
13
19
  * Examples:
14
20
  * DEBUGG_TUNNEL_FAULT_MODE=fail-connect-N:2
15
21
  * DEBUGG_TUNNEL_FAULT_MODE=delay-connect:2000,fail-connect-N:1
22
+ * DEBUGG_TUNNEL_FAULT_MODE=inspector-adopt:3000
16
23
  */
17
24
  export function parseFaultMode(raw) {
18
25
  if (!raw)
19
26
  return null;
20
27
  const mode = {};
21
28
  for (const token of raw.split(',').map((s) => s.trim()).filter(Boolean)) {
22
- const m = token.match(/^(fail-connect-N|empty-url-N|delay-connect):(\d+)$/);
29
+ const m = token.match(/^(fail-connect-N|empty-url-N|delay-connect|inspector-unreachable|inspector-adopt):(\d+)$/);
23
30
  if (!m)
24
31
  continue;
25
32
  const [, name, valStr] = m;
@@ -30,6 +37,12 @@ export function parseFaultMode(raw) {
30
37
  mode.emptyUrlN = val;
31
38
  else if (name === 'delay-connect')
32
39
  mode.delayConnectMs = val;
40
+ // Value-less switch, but kept in the `name:<digits>` grammar so one regex
41
+ // covers every mode. Any non-zero value turns it on.
42
+ else if (name === 'inspector-unreachable')
43
+ mode.inspectorUnreachable = val !== 0;
44
+ else if (name === 'inspector-adopt')
45
+ mode.inspectorAdoptPort = val;
33
46
  }
34
47
  return Object.keys(mode).length > 0 ? mode : null;
35
48
  }
@@ -23,6 +23,7 @@ import { v4 as uuidv4 } from 'uuid';
23
23
  import { FaultInjector, TunnelTrace, getFaultModeFromEnv } from './tunnelFaultInjection.js';
24
24
  import { getDefaultRegistry, } from './tunnelRegistry.js';
25
25
  import { startAgentSession } from './ngrokAgentSession.js';
26
+ import { getDefaultInspector } from './ngrokAgentInspector.js';
26
27
  let ngrokModule = null;
27
28
  async function getNgrok() {
28
29
  if (!ngrokModule) {
@@ -49,18 +50,51 @@ class TunnelManager {
49
50
  activeTunnels = new Map();
50
51
  pendingTunnels = new Map();
51
52
  initialized = false;
52
- TUNNEL_TIMEOUT_MS = 55 * 60 * 1000;
53
+ /**
54
+ * Idle window before an owned tunnel auto-shuts-off. THE constant of this
55
+ * class: every other lifetime below is derived from it, because they are all
56
+ * answering the same question — "could this tunnel still be alive?" — and
57
+ * when they answered it differently they cost real money (bead y7x6).
58
+ *
59
+ * Public so timer tests can run in milliseconds instead of 55 minutes,
60
+ * matching the `connectBackoffMs` / `agentSessionStarter` precedent.
61
+ */
62
+ idleTimeoutMs = 55 * 60 * 1000;
53
63
  /**
54
64
  * Bead `3th`: registry-entry freshness window. An entry not touched within
55
65
  * this many ms is treated as stale even if its owner PID is alive — defends
56
66
  * against PID-reuse (OS reassigns dead-owner's PID to a different process).
67
+ *
68
+ * Bead `y7x6`: this was a hard-coded 30 minutes while tunnels lived for 55,
69
+ * so between T+30 and T+55 an entry was judged unusable while the tunnel it
70
+ * named was alive and billing. The next request provisioned a duplicate and
71
+ * OVERWROTE the entry, orphaning the original: a systematic double-bill on a
72
+ * 25-minute-wide window. Deriving it from the idle timeout is the fix, and
73
+ * it is the derivation — not the number — that matters, because it makes the
74
+ * two impossible to drift apart again.
75
+ *
76
+ * No guard band is subtracted. A band would just re-open a narrower version
77
+ * of the same window, and the T+55 boundary is already handled: a borrower
78
+ * writes `lastAccessedAt` before the owner's timer fires, and the owner then
79
+ * extends rather than shutting down.
57
80
  */
58
- REGISTRY_FRESHNESS_TTL_MS = 30 * 60 * 1000;
81
+ get registryFreshnessTtlMs() {
82
+ return this.idleTimeoutMs;
83
+ }
59
84
  /**
60
85
  * Bead `mdp`: prune-on-startup eviction window. Entries older than this OR
61
86
  * with dead owner PID get swept out when TunnelManager initializes.
87
+ *
88
+ * Also derived, for the same reason: an entry older than the idle timeout
89
+ * names a tunnel that has already auto-shut-off, and one that has NOT is
90
+ * recovered by re-adoption (bead lc62) rather than by keeping a longer
91
+ * threshold here. Pruning something `isEntryUsable` already rejects costs
92
+ * nothing; the danger was never prune's window, it was that nothing put a
93
+ * live tunnel back.
62
94
  */
63
- REGISTRY_PRUNE_THRESHOLD_MS = 60 * 60 * 1000;
95
+ get registryPruneThresholdMs() {
96
+ return this.idleTimeoutMs;
97
+ }
64
98
  /**
65
99
  * Backoff schedule (ms) between ngrok.connect() retry attempts. Bead ixh.
66
100
  * Exposed on the class so tests can override with short delays without
@@ -80,17 +114,31 @@ class TunnelManager {
80
114
  * a hang.
81
115
  */
82
116
  agentSessionTimeoutMs = 5000;
117
+ /**
118
+ * Bead lc62: where we learn which tunnels are actually alive on this machine.
119
+ * Overridable so tests drive a fake agent API instead of loopback HTTP.
120
+ */
121
+ tunnelInspector = getDefaultInspector();
83
122
  /** Whether the ngrok agent's client session is established (bead pqgj). */
84
123
  agentSessionReady = false;
85
124
  /** In-flight session bootstrap, so concurrent tunnels wait on one spawn. */
86
125
  agentSessionPromise = null;
126
+ /** Memoized one-shot reconcile against the local ngrok agents (bead lc62). */
127
+ reconcilePromise = null;
87
128
  constructor(reg = getDefaultRegistry()) {
88
129
  this.reg = reg;
89
130
  // Bead `mdp`: sweep stale entries on startup so the registry doesn't grow
90
131
  // unboundedly across MCP processes that exited without stopAllTunnels
91
132
  // (SIGKILL / crash). Best-effort — no-op registries don't actually prune.
133
+ //
134
+ // Bead lc62: this sweep deletes map keys, which cannot stop a tunnel, so a
135
+ // pruned-but-live tunnel becomes an invisible billing line. Making prune
136
+ // tear tunnels down would be far worse — two billed hours for every idle
137
+ // gap, on exactly the days-long sessions this design exists to serve — so
138
+ // recovery is handled the other way round, by reconcileWithLocalAgents()
139
+ // putting live tunnels BACK. Prune stays cheap, synchronous, and harmless.
92
140
  try {
93
- const result = this.reg.prune({ staleAfterMs: this.REGISTRY_PRUNE_THRESHOLD_MS });
141
+ const result = this.reg.prune({ staleAfterMs: this.registryPruneThresholdMs });
94
142
  if (result.pruned > 0) {
95
143
  logger.info(`Pruned ${result.pruned} stale registry entries on startup (${result.remaining} remaining)`);
96
144
  }
@@ -105,7 +153,7 @@ class TunnelManager {
105
153
  */
106
154
  isEntryUsable(entry, nowMs = Date.now()) {
107
155
  return (this.reg.isPidAlive(entry.ownerPid) &&
108
- (nowMs - entry.lastAccessedAt) <= this.REGISTRY_FRESHNESS_TTL_MS);
156
+ (nowMs - entry.lastAccessedAt) <= this.registryFreshnessTtlMs);
109
157
  }
110
158
  // ── Public API ──────────────────────────────────────────────────────────────
111
159
  async processUrl(url, authToken, specificTunnelId, keyId, revokeKey) {
@@ -132,7 +180,7 @@ class TunnelManager {
132
180
  return undefined;
133
181
  if (!existing.isOwned) {
134
182
  // Verify the owning process is still alive AND the entry is fresh
135
- // (lastAccessedAt within REGISTRY_FRESHNESS_TTL_MS — defends against
183
+ // (lastAccessedAt within registryFreshnessTtlMs — defends against
136
184
  // PID-reuse per bead 3th).
137
185
  const entry = this.reg.read()[String(port)];
138
186
  if (!entry || !this.isEntryUsable(entry)) {
@@ -184,25 +232,61 @@ class TunnelManager {
184
232
  // best-effort — a failed eviction just means the next call re-probes and re-evicts
185
233
  }
186
234
  }
235
+ /**
236
+ * Mark a tunnel as in-use: refresh the shared registry entry so the owner
237
+ * does not auto-shut-off underneath us, and reset the local idle timer.
238
+ *
239
+ * Bead lc62 — two changes here, both about the registry telling the truth:
240
+ *
241
+ * 1. The refresh is now scoped to OUR tunnelId. It used to refresh whatever
242
+ * entry held our port, so using tunnel A kept tunnel B's entry alive after
243
+ * B had replaced A on that port.
244
+ * 2. If we OWN the tunnel and the entry has gone missing, we put it back.
245
+ * Nothing did this before: prune (or a registry the process could not see,
246
+ * bead fcbm) deleted the entry, and since the in-process reuse path never
247
+ * wrote to the registry, the tunnel stayed live, stayed billing, and
248
+ * stayed permanently invisible to every other MCP on the machine. One file
249
+ * write makes that self-heal, and it cannot churn a tunnel because it only
250
+ * ever ADDS the entry for a tunnel this process is holding open.
251
+ *
252
+ * A foreign entry — same port, different tunnelId — is left completely alone.
253
+ * That is another process's live tunnel; overwriting it would displace it.
254
+ */
187
255
  touchTunnel(tunnelId) {
188
256
  const tunnelInfo = this.activeTunnels.get(tunnelId);
189
257
  if (!tunnelInfo)
190
258
  return;
191
- // Refresh the shared registry entry so the owning process won't auto-shutoff
192
- // while we're actively using the tunnel (even if we're borrowing it).
193
259
  try {
194
260
  const registry = this.reg.read();
195
- const entry = registry[String(tunnelInfo.port)];
196
- if (entry) {
261
+ const key = String(tunnelInfo.port);
262
+ const entry = registry[key];
263
+ if (entry?.tunnelId === tunnelInfo.tunnelId) {
197
264
  entry.lastAccessedAt = Date.now();
198
265
  this.reg.write(registry);
199
266
  }
267
+ else if (!entry && tunnelInfo.isOwned) {
268
+ registry[key] = this.registryEntryFor(tunnelInfo);
269
+ this.reg.write(registry);
270
+ logger.info(`Re-registered owned tunnel ${tunnelInfo.tunnelId} for port ${tunnelInfo.port} — ` +
271
+ 'its registry entry had gone missing while the tunnel was still live (bead lc62).');
272
+ }
200
273
  }
201
274
  catch {
202
275
  // best-effort
203
276
  }
204
277
  this.resetTunnelTimer(tunnelInfo);
205
278
  }
279
+ /** The shared-registry view of a tunnel this process owns. */
280
+ registryEntryFor(tunnelInfo) {
281
+ return {
282
+ tunnelId: tunnelInfo.tunnelId,
283
+ publicUrl: tunnelInfo.publicUrl,
284
+ tunnelUrl: tunnelInfo.tunnelUrl,
285
+ port: tunnelInfo.port,
286
+ ownerPid: process.pid,
287
+ lastAccessedAt: Date.now(),
288
+ };
289
+ }
206
290
  touchTunnelByUrl(url) {
207
291
  const tunnelId = this.extractTunnelId(url);
208
292
  if (tunnelId) {
@@ -238,11 +322,19 @@ class TunnelManager {
238
322
  Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, { port: tunnelInfo.port, reason: 'released', isOwned: false });
239
323
  return;
240
324
  }
241
- // Owned — remove from shared registry, then disconnect + revoke
325
+ // Owned — remove from shared registry, then disconnect + revoke.
326
+ // Guarded by tunnelId (bead lc62, same reasoning as the auto-shutoff check
327
+ // and markTunnelDead): if a replacement already holds this port's entry,
328
+ // deleting it would strand ITS live tunnel and buy the next caller a
329
+ // duplicate. Only ever remove the entry that names the tunnel we are
330
+ // actually stopping.
242
331
  try {
243
332
  const registry = this.reg.read();
244
- delete registry[String(tunnelInfo.port)];
245
- this.reg.write(registry);
333
+ const key = String(tunnelInfo.port);
334
+ if (registry[key]?.tunnelId === tunnelInfo.tunnelId) {
335
+ delete registry[key];
336
+ this.reg.write(registry);
337
+ }
246
338
  }
247
339
  catch {
248
340
  // best-effort
@@ -281,7 +373,7 @@ class TunnelManager {
281
373
  tunnel,
282
374
  age: now - tunnel.createdAt,
283
375
  timeSinceLastAccess: now - tunnel.lastAccessedAt,
284
- timeUntilAutoShutoff: Math.max(0, tunnel.lastAccessedAt + this.TUNNEL_TIMEOUT_MS - now),
376
+ timeUntilAutoShutoff: Math.max(0, tunnel.lastAccessedAt + this.idleTimeoutMs - now),
285
377
  };
286
378
  }
287
379
  getAllTunnelStatuses() {
@@ -300,6 +392,12 @@ class TunnelManager {
300
392
  if (existing) {
301
393
  // Bead zmc9: retarget to THIS caller's path; publicUrl carries the creator's.
302
394
  const url_ = retargetTunnelUrl(existing.tunnelUrl, url);
395
+ // Bead lc62: reuse used to return straight from the in-process map without
396
+ // touching the registry at all, so a tunnel could be in constant use and
397
+ // still look abandoned to every other MCP — and its own idle timer kept
398
+ // counting down. touchTunnel refreshes (or restores) the shared entry and
399
+ // resets that timer, which is what "this tunnel is in use" should mean.
400
+ this.touchTunnel(existing.tunnelId);
303
401
  logger.info(`Reusing existing tunnel for port ${port}: ${url_}`);
304
402
  Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { port, how: 'reused' });
305
403
  return { url: url_, tunnelId: existing.tunnelId, isLocalhost: true };
@@ -320,34 +418,20 @@ class TunnelManager {
320
418
  }
321
419
  // 3. Check cross-process registry — another MCP instance may own a tunnel.
322
420
  // Borrow only if the entry is fresh (PID alive AND touched within
323
- // REGISTRY_FRESHNESS_TTL_MS — defends against PID-reuse, bead 3th).
421
+ // registryFreshnessTtlMs — defends against PID-reuse, bead 3th).
324
422
  const registry = this.reg.read();
325
423
  const regEntry = registry[String(port)];
326
424
  if (regEntry && this.isEntryUsable(regEntry)) {
327
- logger.info(`Borrowing tunnel from PID ${regEntry.ownerPid} for port ${port}: ${regEntry.publicUrl}`);
328
- const now = Date.now();
329
- const borrowed = {
330
- tunnelId: regEntry.tunnelId,
331
- originalUrl: url,
332
- tunnelUrl: regEntry.tunnelUrl,
333
- publicUrl: regEntry.publicUrl,
334
- port,
335
- createdAt: now,
336
- lastAccessedAt: now,
337
- isOwned: false,
338
- };
339
- this.activeTunnels.set(regEntry.tunnelId, borrowed);
340
- // Touch registry so the owner knows not to auto-shutoff
341
- regEntry.lastAccessedAt = now;
342
- this.reg.write(registry);
343
- this.resetTunnelTimer(borrowed);
344
- Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { port, how: 'borrowed' });
425
+ const borrowed = this.borrowRegistryEntry(regEntry, url, registry);
345
426
  // Bead zmc9: retarget to THIS caller's path; regEntry.publicUrl carries the
346
427
  // owning PID's creating-call path — replaying it is the cross-session poison.
347
- return { url: retargetTunnelUrl(regEntry.tunnelUrl, url), tunnelId: regEntry.tunnelId, isLocalhost: true };
428
+ return { url: retargetTunnelUrl(borrowed.tunnelUrl, url), tunnelId: borrowed.tunnelId, isLocalhost: true };
348
429
  }
349
- // 4. Create a new tunnel (this process becomes the owner)
350
- const creationPromise = this.createTunnel(url, port, tunnelId, authToken, keyId, revokeKey);
430
+ // 4. Nothing to reuse. Publish the pending promise SYNCHRONOUSLY — every
431
+ // check above is synchronous precisely so a second caller arriving in
432
+ // this same tick joins us rather than buying a second hour — and do the
433
+ // slow work (agent reconcile, then connect) inside it.
434
+ const creationPromise = this.adoptOrCreateTunnel(url, port, tunnelId, authToken, keyId, revokeKey);
351
435
  this.pendingTunnels.set(port, creationPromise);
352
436
  let tunnelInfo;
353
437
  try {
@@ -356,7 +440,123 @@ class TunnelManager {
356
440
  finally {
357
441
  this.pendingTunnels.delete(port);
358
442
  }
359
- return { url: tunnelInfo.publicUrl, tunnelId: tunnelInfo.tunnelId, isLocalhost: true };
443
+ // A tunnel we just created carries this caller's path in publicUrl; an
444
+ // ADOPTED one carries someone else's, so retarget (bead zmc9).
445
+ const resolvedUrl = tunnelInfo.isOwned
446
+ ? tunnelInfo.publicUrl
447
+ : retargetTunnelUrl(tunnelInfo.tunnelUrl, url);
448
+ return { url: resolvedUrl, tunnelId: tunnelInfo.tunnelId, isLocalhost: true };
449
+ }
450
+ /**
451
+ * Take a live registry entry into this process as a BORROWED tunnel, and
452
+ * stamp it as touched so its owner does not auto-shut-off underneath us.
453
+ */
454
+ borrowRegistryEntry(entry, url, registry) {
455
+ logger.info(`Borrowing tunnel from PID ${entry.ownerPid} for port ${entry.port}: ${entry.publicUrl}`);
456
+ const now = Date.now();
457
+ const borrowed = {
458
+ tunnelId: entry.tunnelId,
459
+ originalUrl: url,
460
+ tunnelUrl: entry.tunnelUrl,
461
+ publicUrl: entry.publicUrl,
462
+ port: entry.port,
463
+ createdAt: now,
464
+ lastAccessedAt: now,
465
+ isOwned: false,
466
+ };
467
+ this.activeTunnels.set(entry.tunnelId, borrowed);
468
+ entry.lastAccessedAt = now;
469
+ try {
470
+ this.reg.write(registry);
471
+ }
472
+ catch {
473
+ // best-effort
474
+ }
475
+ this.resetTunnelTimer(borrowed);
476
+ Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { port: entry.port, how: 'borrowed' });
477
+ return borrowed;
478
+ }
479
+ /**
480
+ * Last stop before spending a billed hour (bead lc62).
481
+ *
482
+ * The registry says there is nothing to reuse for this port. Before believing
483
+ * it, ask the local ngrok agents what is ACTUALLY running: a tunnel whose
484
+ * owner was SIGKILLed, or whose entry got pruned or written to a registry
485
+ * this process could not see, is still open and still billing, and the
486
+ * registry is simply wrong about it. Re-adopting one costs a loopback GET;
487
+ * not adopting it costs an hour for the replacement plus the remaining hour
488
+ * of the orphan nobody is using.
489
+ *
490
+ * Reaping orphans is deliberately NOT done here. Once they can be re-adopted
491
+ * an orphan pointing at a live port is an asset, and killing it only
492
+ * guarantees we buy that hour again later.
493
+ */
494
+ async adoptOrCreateTunnel(url, port, tunnelId, authToken, keyId, revokeKey) {
495
+ await this.reconcileWithLocalAgents();
496
+ const registry = this.reg.read();
497
+ const entry = registry[String(port)];
498
+ if (entry && this.isEntryUsable(entry)) {
499
+ logger.info(`Adopted live tunnel ${entry.tunnelId} for port ${port} instead of provisioning a new one`);
500
+ return this.borrowRegistryEntry(entry, url, registry);
501
+ }
502
+ return this.createTunnel(url, port, tunnelId, authToken, keyId, revokeKey);
503
+ }
504
+ /**
505
+ * Reconcile the shared registry against the tunnels the local ngrok agents
506
+ * report (bead lc62). Runs at most once per process, lazily — on the first
507
+ * request that would otherwise provision — so importing this module never
508
+ * touches the network and a process that only ever borrows never pays for it.
509
+ *
510
+ * ADD-ONLY, and that is the whole safety argument. This can create an entry
511
+ * or refresh an unusable one; it can never delete or invalidate anything. So
512
+ * an agent that is down, a scan that misses the right port, or a parse that
513
+ * fails all degrade to "learned nothing" and leave behaviour exactly as it is
514
+ * today. Nothing this function can get wrong is able to cost a re-provision.
515
+ *
516
+ * A usable entry is never disturbed, even by a live tunnel claiming the same
517
+ * port: that entry is somebody's working tunnel and displacing it would
518
+ * strand a paid-for session.
519
+ */
520
+ reconcileWithLocalAgents() {
521
+ if (!this.reconcilePromise) {
522
+ this.reconcilePromise = (async () => {
523
+ const live = await this.tunnelInspector.listLiveTunnels();
524
+ if (live.length === 0)
525
+ return;
526
+ const registry = this.reg.read();
527
+ const adopted = [];
528
+ for (const tunnel of live) {
529
+ const key = String(tunnel.port);
530
+ const entry = registry[key];
531
+ // Somebody's working entry — never touch it, even to "correct" it.
532
+ if (entry && this.isEntryUsable(entry))
533
+ continue;
534
+ registry[key] = {
535
+ tunnelId: tunnel.tunnelId,
536
+ publicUrl: tunnel.publicUrl,
537
+ tunnelUrl: tunnel.publicUrl,
538
+ port: tunnel.port,
539
+ // We are not the ngrok owner and never claim to be — TunnelInfo for
540
+ // this entry is always built with isOwned:false. ownerPid is the
541
+ // registry's liveness proxy, and pointing it at a live process is
542
+ // what makes the entry borrowable at all.
543
+ ownerPid: process.pid,
544
+ lastAccessedAt: Date.now(),
545
+ };
546
+ adopted.push(`${tunnel.tunnelId}→${tunnel.port}`);
547
+ }
548
+ if (adopted.length > 0) {
549
+ this.reg.write(registry);
550
+ logger.info(`Re-adopted ${adopted.length} live ngrok tunnel(s) the registry had lost: ${adopted.join(', ')}. ` +
551
+ 'Each one saves provisioning a duplicate for a port that is already served.');
552
+ }
553
+ })().catch((err) => {
554
+ // An inspector failure must never block tunnelling — it only ever had
555
+ // the power to save us money, never to authorise anything.
556
+ logger.debug(`ngrok agent reconcile unavailable (non-fatal): ${err}`);
557
+ });
558
+ }
559
+ return this.reconcilePromise;
360
560
  }
361
561
  findTunnelByPort(port) {
362
562
  for (const tunnel of this.activeTunnels.values()) {
@@ -619,10 +819,21 @@ class TunnelManager {
619
819
  tunnelInfo.autoShutoffTimer = setTimeout(async () => {
620
820
  // For owned tunnels: if another process recently touched the registry entry,
621
821
  // reset the timer rather than disconnecting — that process is still using it.
822
+ //
823
+ // Bead lc62: the entry has to be OURS. This lookup is keyed by port, and
824
+ // the check used to stop at the timestamp, so once a replacement tunnel
825
+ // took over the port the displaced tunnel read the replacement's activity
826
+ // as its own and extended itself — forever, since every extension found
827
+ // the entry fresh again. That is what turned a 55-minute mistake into a
828
+ // multi-day one: an orphan nobody could reach, billing indefinitely.
829
+ // Comparing tunnelId makes an orphan simply time out, 55 idle minutes
830
+ // after the last time anyone actually used IT.
622
831
  if (tunnelInfo.isOwned) {
623
832
  try {
624
833
  const entry = this.reg.read()[String(tunnelInfo.port)];
625
- if (entry && Date.now() - entry.lastAccessedAt < this.TUNNEL_TIMEOUT_MS) {
834
+ if (entry &&
835
+ entry.tunnelId === tunnelInfo.tunnelId &&
836
+ Date.now() - entry.lastAccessedAt < this.idleTimeoutMs) {
626
837
  logger.info(`Tunnel ${tunnelInfo.tunnelId} accessed by another process — extending lifetime`);
627
838
  this.resetTunnelTimer(tunnelInfo);
628
839
  return;
@@ -635,7 +846,7 @@ class TunnelManager {
635
846
  logger.info(`Auto-shutting down tunnel ${tunnelInfo.tunnelId} after inactivity`);
636
847
  Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, { port: tunnelInfo.port, reason: 'auto-shutoff', isOwned: tunnelInfo.isOwned });
637
848
  await this.stopTunnel(tunnelInfo.tunnelId).catch((err) => logger.error(`Failed to auto-shutdown tunnel ${tunnelInfo.tunnelId}:`, err));
638
- }, this.TUNNEL_TIMEOUT_MS);
849
+ }, this.idleTimeoutMs);
639
850
  }
640
851
  }
641
852
  const tunnelManager = new TunnelManager();
@@ -7,29 +7,109 @@
7
7
  * The file registry uses an atomic rename-write so concurrent processes never
8
8
  * see a partial JSON file. All operations are best-effort — errors are
9
9
  * swallowed so a broken registry never blocks tunnel creation.
10
+ *
11
+ * Bead `fcbm` — WHERE the file lives is load-bearing. It used to be
12
+ * `join(tmpdir(), 'debugg-ai-tunnels.json')`, and `os.tmpdir()` honours
13
+ * `$TMPDIR`, which is not a property of the machine — it is a property of how
14
+ * the process was LAUNCHED. Under launchd it is a per-user
15
+ * `/var/folders/<...>/T`; a shell with a scrubbed environment gets `/tmp`; the
16
+ * Docker image gets `/tmp`. Two MCPs started differently therefore kept two
17
+ * SEPARATE registries, never saw each other's tunnels, and provisioned a
18
+ * duplicate per port — silently and permanently. At a 1-hour minimum charge per
19
+ * tunnel that is a guaranteed double-bill, not an edge case.
20
+ *
21
+ * So the path is pinned to `~/.debugg-ai/tunnels.json`, which is the same file
22
+ * for the same user no matter how the process was started, with a
23
+ * `DEBUGG_AI_TUNNEL_REGISTRY` override for containers that mount a shared
24
+ * volume elsewhere. Anything found at the legacy tmpdir() path is merged in
25
+ * once, so tunnels already paid for on the old path stay discoverable.
26
+ */
27
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'fs';
28
+ import { homedir, tmpdir } from 'os';
29
+ import { dirname, join } from 'path';
30
+ // ── File location ─────────────────────────────────────────────────────────────
31
+ /**
32
+ * The pre-fcbm location. Still READ (and merged from) so tunnels provisioned by
33
+ * an older build, or by a build whose $TMPDIR differed, are not stranded.
34
+ *
35
+ * Never written to, and never deleted: a still-running older MCP is reading it,
36
+ * and removing its entries would make it re-provision — the exact double-bill
37
+ * this bead exists to stop.
38
+ */
39
+ export function getLegacyRegistryFilePath() {
40
+ return join(tmpdir(), 'debugg-ai-tunnels.json');
41
+ }
42
+ /**
43
+ * The registry path for this machine+user. Resolved per call (not cached at
44
+ * module load) so an override can be set before the registry is constructed.
45
+ */
46
+ export function getRegistryFilePath() {
47
+ const override = process.env.DEBUGG_AI_TUNNEL_REGISTRY?.trim();
48
+ if (override)
49
+ return override;
50
+ return join(homedir(), '.debugg-ai', 'tunnels.json');
51
+ }
52
+ /** Paths already merged in this process — the legacy merge is a one-shot. */
53
+ const migratedPaths = new Set();
54
+ /**
55
+ * Per-path watermark: when we last wrote a complete view of this registry.
56
+ *
57
+ * The read-side legacy overlay needs to tell two cases apart that look identical
58
+ * in the file: an entry we have ALREADY considered and deliberately swept, and
59
+ * one an old-build MCP wrote after we swept. Resurrecting the first hands a dead
60
+ * tunnel to the next borrower (bead k34o); missing the second buys a duplicate.
61
+ * An entry's `lastAccessedAt` relative to our last write separates them.
62
+ *
63
+ * Ties and clock skew resolve toward NOT resurrecting, because the two mistakes
64
+ * are not equal: a dead entry breaks a run, a missed one costs an hour we were
65
+ * already spending before this path moved.
10
66
  */
11
- import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
12
- import { tmpdir } from 'os';
13
- import { join } from 'path';
67
+ const sweptAt = new Map();
14
68
  // ── File-backed implementation (production) ───────────────────────────────────
15
- const REGISTRY_FILE = join(tmpdir(), 'debugg-ai-tunnels.json');
16
- export function createFileRegistry() {
69
+ /**
70
+ * Both paths are parameters rather than module constants so tests can point at
71
+ * a throwaway directory. That matters more than it looks: the legacy path is a
72
+ * real machine-wide file that a running MCP may depend on, and `os.tmpdir()`
73
+ * does not observe a `TMPDIR` set inside a Jest realm — so a test that tried to
74
+ * redirect it by environment would silently read and REWRITE the developer's
75
+ * actual registry.
76
+ */
77
+ export function createFileRegistry(registryFile = getRegistryFilePath(), legacyFile = getLegacyRegistryFilePath()) {
17
78
  const store = {
18
79
  read() {
19
- try {
20
- if (!existsSync(REGISTRY_FILE))
21
- return {};
22
- return JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
23
- }
24
- catch {
25
- return {};
26
- }
80
+ // Overlay the legacy registry on EVERY read, not just once at startup.
81
+ //
82
+ // Moving the path partitions us against every MCP still running the old
83
+ // build, which keeps writing to tmpdir() — i.e. for the length of the
84
+ // rollout this change would REINTRODUCE the split-registry double-billing
85
+ // it exists to fix. That window is not short: long-lived sessions here run
86
+ // for days, so a one-shot merge at process start would miss every tunnel
87
+ // an old build provisions afterwards.
88
+ //
89
+ // Reading both and preferring the fresher record closes the direction that
90
+ // matters — new builds see old builds' tunnels and borrow them instead of
91
+ // buying duplicates. The reverse (old builds seeing ours) cannot be fixed
92
+ // from this side without dual-writing, which would hand the old code a file
93
+ // it may prune on our behalf; it resolves as old sessions exit.
94
+ //
95
+ // Cost is one extra existsSync + small JSON read per lookup, against a
96
+ // 1-hour minimum charge for getting it wrong.
97
+ return overlayLegacy(readRegistryFile(registryFile), legacyFile, registryFile);
27
98
  },
28
99
  write(data) {
29
- const tmp = `${REGISTRY_FILE}.${process.pid}.tmp`;
100
+ const tmp = `${registryFile}.${process.pid}.tmp`;
30
101
  try {
102
+ // 0o700: the registry names every local port this user is exposing.
103
+ // Also covers a first run where ~/.debugg-ai does not exist yet, and a
104
+ // later run where someone removed it.
105
+ mkdirSync(dirname(registryFile), { recursive: true, mode: 0o700 });
31
106
  writeFileSync(tmp, JSON.stringify(data));
32
- renameSync(tmp, REGISTRY_FILE);
107
+ // Same directory as the target, so the rename is same-filesystem and
108
+ // therefore actually atomic.
109
+ renameSync(tmp, registryFile);
110
+ // We have just published a complete view; anything older in the legacy
111
+ // file has already been considered (and possibly swept) by us.
112
+ sweptAt.set(registryFile, Date.now());
33
113
  }
34
114
  catch {
35
115
  // best-effort
@@ -42,8 +122,87 @@ export function createFileRegistry() {
42
122
  return pruneRegistryData(store, opts);
43
123
  },
44
124
  };
125
+ mergeLegacyRegistry(store, registryFile, legacyFile);
45
126
  return store;
46
127
  }
128
+ /**
129
+ * One-shot merge of the legacy tmpdir() registry into the stable one.
130
+ *
131
+ * Merge, not move: an entry only wins if this path has nothing for that port or
132
+ * has something older. A tunnel that both files know about keeps whichever
133
+ * record was touched most recently, which is the one whose `lastAccessedAt`
134
+ * actually reflects use.
135
+ */
136
+ function mergeLegacyRegistry(store, registryFile, legacyFile) {
137
+ if (legacyFile === registryFile || migratedPaths.has(registryFile))
138
+ return;
139
+ migratedPaths.add(registryFile);
140
+ const legacy = readRegistryFile(legacyFile);
141
+ const ports = Object.keys(legacy);
142
+ if (ports.length === 0)
143
+ return;
144
+ const current = store.read();
145
+ let merged = 0;
146
+ for (const port of ports) {
147
+ const entry = legacy[port];
148
+ if (!isRegistryEntry(entry))
149
+ continue;
150
+ const mine = current[port];
151
+ if (!mine || entry.lastAccessedAt > mine.lastAccessedAt) {
152
+ current[port] = entry;
153
+ merged++;
154
+ }
155
+ }
156
+ if (merged > 0)
157
+ store.write(current);
158
+ }
159
+ /**
160
+ * Merge the legacy registry over `current` in memory, fresher record winning.
161
+ *
162
+ * Read-side only — never writes. An old-build MCP that provisions a tunnel after
163
+ * our one-shot migration ran is otherwise invisible to us, and we would buy a
164
+ * duplicate for a port it already has covered.
165
+ */
166
+ function overlayLegacy(current, legacyFile, registryFile) {
167
+ if (legacyFile === registryFile)
168
+ return current;
169
+ const watermark = sweptAt.get(registryFile) ?? 0;
170
+ const legacy = readRegistryFile(legacyFile);
171
+ for (const [port, entry] of Object.entries(legacy)) {
172
+ if (!isRegistryEntry(entry))
173
+ continue;
174
+ // Older than our last complete write => we already saw it and chose not to
175
+ // keep it. Bringing it back would undo a prune and re-borrow a dead tunnel.
176
+ if (entry.lastAccessedAt <= watermark)
177
+ continue;
178
+ const mine = current[port];
179
+ if (!mine || entry.lastAccessedAt > mine.lastAccessedAt)
180
+ current[port] = entry;
181
+ }
182
+ return current;
183
+ }
184
+ function readRegistryFile(file) {
185
+ try {
186
+ if (!existsSync(file))
187
+ return {};
188
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
189
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
190
+ return {};
191
+ return parsed;
192
+ }
193
+ catch {
194
+ return {};
195
+ }
196
+ }
197
+ function isRegistryEntry(value) {
198
+ const e = value;
199
+ return (!!e &&
200
+ typeof e === 'object' &&
201
+ typeof e.tunnelId === 'string' &&
202
+ typeof e.port === 'number' &&
203
+ typeof e.ownerPid === 'number' &&
204
+ typeof e.lastAccessedAt === 'number');
205
+ }
47
206
  // ── In-memory implementation (tests / injectable) ─────────────────────────────
48
207
  export function createInMemoryRegistry(isPidAliveImpl) {
49
208
  let data = {};
@@ -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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@debugg-ai/debugg-ai-mcp",
3
- "version": "3.9.2",
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": {