@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.
@@ -18,7 +18,7 @@ import { Logger } from '../utils/logger.js';
18
18
  import { handleExternalServiceError } from '../utils/errors.js';
19
19
  import { DebuggAIServerClient } from '../services/index.js';
20
20
  import { TunnelProvisionError } from '../services/tunnels.js';
21
- import { tunnelManager } from '../services/ngrok/tunnelManager.js';
21
+ import { disposeUnhealthyTunnel } from '../utils/tunnelDisposition.js';
22
22
  import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js';
23
23
  import { extractLocalhostPort } from '../utils/urlParser.js';
24
24
  import { buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
@@ -148,18 +148,12 @@ export async function probePageHandler(input, context, rawProgressCallback) {
148
148
  elapsedMs: health.elapsedMs,
149
149
  },
150
150
  };
151
- if (tunneled.tunnelId) {
152
- const deadPort = extractLocalhostPort(tunneled.originalUrl);
153
- if (health.ngrokErrorCode && typeof deadPort === 'number') {
154
- // Tunnel-level dead (ERR_NGROK_*): evict the SHARED registry entry
155
- // so the next call re-provisions instead of re-borrowing the corpse
156
- // (bead k34o) plain stopTunnel leaves a borrowed entry to re-poison.
157
- tunnelManager.markTunnelDead(deadPort, tunneled.tunnelId).catch((err) => logger.warn(`Failed to evict dead tunnel ${tunneled.tunnelId}: ${err}`));
158
- }
159
- else {
160
- tunnelManager.stopTunnel(tunneled.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${tunneled.tunnelId}: ${err}`));
161
- }
162
- }
151
+ // Evict ONLY on a code proving the endpoint is gone; every other
152
+ // failure keeps the tunnel we are already paying for, since a
153
+ // teardown+re-provision costs two billed hours and this probe
154
+ // cannot tell a dead endpoint from a transient edge flake. See
155
+ // utils/tunnelDisposition.ts for the allowlist and the evidence.
156
+ disposeUnhealthyTunnel({ health, tunnelId: tunneled.tunnelId, originalUrl: tunneled.originalUrl });
163
157
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
164
158
  }
165
159
  }
@@ -2,7 +2,7 @@ import { Logger } from '../utils/logger.js';
2
2
  import { handleExternalServiceError } from '../utils/errors.js';
3
3
  import { DebuggAIServerClient } from '../services/index.js';
4
4
  import { TunnelProvisionError } from '../services/tunnels.js';
5
- import { tunnelManager } from '../services/ngrok/tunnelManager.js';
5
+ import { disposeUnhealthyTunnel } from '../utils/tunnelDisposition.js';
6
6
  import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js';
7
7
  import { extractLocalhostPort } from '../utils/urlParser.js';
8
8
  import { buildContext, findExistingTunnel, ensureTunnel } from '../utils/tunnelContext.js';
@@ -82,9 +82,17 @@ export async function runTestSuiteHandler(input, _context) {
82
82
  if (tunneled.targetUrl) {
83
83
  const health = await probeTunnelHealth(tunneled.targetUrl);
84
84
  if (!health.healthy) {
85
- if (tunneled.tunnelId) {
86
- tunnelManager.stopTunnel(tunneled.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${tunneled.tunnelId}: ${err}`));
87
- }
85
+ // Brought in line with the other three handlers, which this one never
86
+ // was (it evicted on EVERY failure and never got bead k34o's shared-
87
+ // registry eviction). Evict only on a code proving the endpoint is
88
+ // gone: a transient edge flake must not cost two billed hours.
89
+ // See utils/tunnelDisposition.ts.
90
+ disposeUnhealthyTunnel({ health, tunnelId: tunneled.tunnelId, originalUrl: ctx.originalUrl });
91
+ // Record the tunnel so the finally block's orphaned-key revoke can't
92
+ // fire: the tunnel we just kept is authenticated with that key, and
93
+ // revoking a live tunnel's credential would kill what we preserved.
94
+ // (On the eviction branch markTunnelDead already revokes it.)
95
+ tunnelId = tunneled.tunnelId;
88
96
  return errorResp('TunnelTrafficBlocked', `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`, { code: health.code, ngrokErrorCode: health.ngrokErrorCode, elapsedMs: health.elapsedMs });
89
97
  }
90
98
  }
@@ -13,7 +13,7 @@ import { adaptVerdict, isEnvironmentDefault } from '../services/verdictAdapter.j
13
13
  import { TunnelProvisionError } from '../services/tunnels.js';
14
14
  import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
15
15
  import { detectRepoName } from '../utils/gitContext.js';
16
- import { tunnelManager } from '../services/ngrok/tunnelManager.js';
16
+ import { disposeUnhealthyTunnel } from '../utils/tunnelDisposition.js';
17
17
  import { probeLocalPort, probeTunnelHealth, extractNgrokErrorCode } from '../utils/localReachability.js';
18
18
  import { extractLocalhostPort } from '../utils/urlParser.js';
19
19
  import { getCachedTemplateUuid, getCachedProjectUuid, invalidateTemplateCache, invalidateProjectCache, } from '../utils/handlerCaches.js';
@@ -276,21 +276,16 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
276
276
  },
277
277
  };
278
278
  logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.ngrokErrorCode ?? ''} in ${health.elapsedMs}ms`);
279
- // Tear down the broken tunnel so a subsequent call doesn't reuse it.
280
- if (ctx.tunnelId) {
281
- const deadPort = extractLocalhostPort(ctx.originalUrl);
282
- if (health.ngrokErrorCode && typeof deadPort === 'number') {
283
- // Tunnel-level dead (ERR_NGROK_*): evict the SHARED registry entry
284
- // so the next call re-provisions instead of re-borrowing the corpse.
285
- // Plain stopTunnel leaves a borrowed entry to re-poison (bead k34o).
286
- tunnelManager.markTunnelDead(deadPort, ctx.tunnelId).catch((err) => logger.warn(`Failed to evict dead tunnel ${ctx.tunnelId}: ${err}`));
287
- }
288
- else {
289
- tunnelManager.stopTunnel(ctx.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${ctx.tunnelId}: ${err}`));
290
- }
291
- }
292
- // keyId is consumed by stopTunnel's revoke path; clear so the
293
- // outer finally block doesn't double-revoke.
279
+ // Evict ONLY on a code proving the endpoint is gone; every other
280
+ // failure keeps the tunnel we are already paying for, because a
281
+ // teardown+re-provision costs two billed hours and this probe cannot
282
+ // tell a dead endpoint from a transient edge flake. See
283
+ // utils/tunnelDisposition.ts for the allowlist and the evidence.
284
+ disposeUnhealthyTunnel({ health, tunnelId: ctx.tunnelId, originalUrl: ctx.originalUrl });
285
+ // Don't revoke the key on either branch: if we evicted, markTunnelDead's
286
+ // owned path already revokes it; if we kept the tunnel, the key is that
287
+ // live tunnel's own credential and revoking it would kill what we just
288
+ // decided to preserve.
294
289
  keyId = undefined;
295
290
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
296
291
  }
@@ -15,7 +15,7 @@ import { Logger } from '../utils/logger.js';
15
15
  import { handleExternalServiceError } from '../utils/errors.js';
16
16
  import { DebuggAIServerClient } from '../services/index.js';
17
17
  import { TunnelProvisionError } from '../services/tunnels.js';
18
- import { tunnelManager } from '../services/ngrok/tunnelManager.js';
18
+ import { disposeUnhealthyTunnel } from '../utils/tunnelDisposition.js';
19
19
  import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js';
20
20
  import { extractLocalhostPort } from '../utils/urlParser.js';
21
21
  import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
@@ -138,18 +138,15 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
138
138
  },
139
139
  };
140
140
  logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.ngrokErrorCode ?? ''} in ${health.elapsedMs}ms`);
141
- if (ctx.tunnelId) {
142
- const deadPort = extractLocalhostPort(ctx.originalUrl);
143
- if (health.ngrokErrorCode && typeof deadPort === 'number') {
144
- // Tunnel-level dead (ERR_NGROK_*): evict the SHARED registry entry so
145
- // the next call re-provisions instead of re-borrowing the corpse — plain
146
- // stopTunnel leaves a borrowed entry to re-poison (bead k34o).
147
- tunnelManager.markTunnelDead(deadPort, ctx.tunnelId).catch((err) => logger.warn(`Failed to evict dead tunnel ${ctx.tunnelId}: ${err}`));
148
- }
149
- else {
150
- tunnelManager.stopTunnel(ctx.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${ctx.tunnelId}: ${err}`));
151
- }
152
- }
141
+ // Evict ONLY on a code proving the endpoint is gone; every other failure
142
+ // keeps the tunnel we are already paying for, because a teardown+
143
+ // re-provision costs two billed hours and this probe cannot tell a dead
144
+ // endpoint from a transient edge flake. See utils/tunnelDisposition.ts.
145
+ disposeUnhealthyTunnel({ health, tunnelId: ctx.tunnelId, originalUrl: ctx.originalUrl });
146
+ // Don't revoke the key on either branch: if we evicted, markTunnelDead's
147
+ // owned path already revokes it; if we kept the tunnel, the key is that
148
+ // live tunnel's own credential and revoking it would kill what we just
149
+ // decided to preserve.
153
150
  keyId = undefined;
154
151
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
155
152
  }
@@ -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
  }