@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.
- package/dist/handlers/probePageHandler.js +7 -13
- package/dist/handlers/runTestSuiteHandler.js +12 -4
- package/dist/handlers/testPageChangesHandler.js +11 -16
- package/dist/handlers/triggerCrawlHandler.js +10 -13
- package/dist/services/ngrok/ngrokAgentInspector.js +210 -0
- package/dist/services/ngrok/tunnelFaultInjection.js +15 -2
- package/dist/services/ngrok/tunnelManager.js +250 -39
- package/dist/services/ngrok/tunnelRegistry.js +174 -15
- package/dist/utils/localReachability.js +148 -6
- package/dist/utils/tunnelDisposition.js +128 -0
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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 {
|
|
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
|
-
|
|
86
|
-
|
|
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 {
|
|
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
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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 {
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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>
|
|
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
|
}
|