@debugg-ai/debugg-ai-mcp 4.2.2 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -4
- package/dist/handlers/createEnvironmentHandler.js +10 -0
- package/dist/handlers/probePageHandler.js +14 -7
- package/dist/handlers/runTestSuiteHandler.js +6 -5
- package/dist/handlers/testPageChangesHandler.js +20 -10
- package/dist/handlers/triggerCrawlHandler.js +7 -6
- package/dist/handlers/updateEnvironmentHandler.js +14 -2
- package/dist/services/index.js +21 -0
- package/dist/services/ngrok/tunnelManager.js +157 -161
- package/dist/services/tunnel/debuggTransport.js +439 -0
- package/dist/services/tunnel/ngrokTransport.js +165 -0
- package/dist/services/tunnel/probeRegistry.js +52 -0
- package/dist/services/tunnel/protocol/codec.js +327 -0
- package/dist/services/tunnel/protocol/conformance.js +171 -0
- package/dist/services/tunnel/protocol/constants.js +178 -0
- package/dist/services/tunnel/protocol/errors.js +51 -0
- package/dist/services/tunnel/protocol/handshake.js +134 -0
- package/dist/services/tunnel/protocol/index.js +17 -0
- package/dist/services/tunnel/protocol/session.js +668 -0
- package/dist/services/tunnel/protocol/stream.js +109 -0
- package/dist/services/tunnel/protocol/transport.js +106 -0
- package/dist/services/tunnel/transport.js +15 -0
- package/dist/services/tunnels.js +71 -3
- package/dist/services/verdictAdapter.js +25 -1
- package/dist/tools/environment.js +5 -2
- package/dist/tools/testPageChanges.js +1 -1
- package/dist/types/index.js +29 -2
- package/dist/utils/authorizedCredentialHosts.js +38 -0
- package/dist/utils/localReachability.js +160 -3
- package/dist/utils/tunnelContext.js +6 -3
- package/dist/utils/tunnelDisposition.js +16 -1
- package/dist/utils/tunnelDomains.js +113 -0
- package/dist/utils/urlParser.js +8 -2
- package/package.json +3 -1
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
* bypasses Caddy/PortLock entirely — see §2.3.
|
|
31
31
|
*/
|
|
32
32
|
import { Logger } from '../../utils/logger.js';
|
|
33
|
+
import { config } from '../../config/index.js';
|
|
33
34
|
import { Telemetry, TelemetryEvents } from '../../utils/telemetry.js';
|
|
34
35
|
import { extractLocalhostPort, generateTunnelUrl } from '../../utils/urlParser.js';
|
|
35
36
|
import { currentApiKey } from '../../utils/requestContext.js';
|
|
@@ -37,28 +38,17 @@ import { createHash } from 'node:crypto';
|
|
|
37
38
|
import { v4 as uuidv4 } from 'uuid';
|
|
38
39
|
import { FaultInjector, TunnelTrace, getFaultModeFromEnv } from './tunnelFaultInjection.js';
|
|
39
40
|
import { getDefaultRegistry, } from './tunnelRegistry.js';
|
|
40
|
-
import { startAgentSession } from './ngrokAgentSession.js';
|
|
41
41
|
import { createCaddyProxy, isDockerEnv, } from '../caddy/caddyProxy.js';
|
|
42
42
|
import { PortLock } from '../caddy/portLock.js';
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
try {
|
|
47
|
-
ngrokModule = await import('ngrok');
|
|
48
|
-
}
|
|
49
|
-
catch (error) {
|
|
50
|
-
throw new Error(`Failed to load ngrok module: ${error}`);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return ngrokModule;
|
|
54
|
-
}
|
|
43
|
+
import { createNgrokTransport, } from '../tunnel/ngrokTransport.js';
|
|
44
|
+
import { createDebuggTransport } from '../tunnel/debuggTransport.js';
|
|
45
|
+
import { extractTunnelIdFromHost, isTunnelHost, } from '../../utils/tunnelDomains.js';
|
|
55
46
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
47
|
+
* The hostname suffix a tunnel gets when the provision response does not name
|
|
48
|
+
* one — i.e. the ngrok path, which is every tunnel until the backend starts
|
|
49
|
+
* selecting the debugg transport.
|
|
58
50
|
*/
|
|
59
|
-
|
|
60
|
-
ngrokModule = null;
|
|
61
|
-
}
|
|
51
|
+
const DEFAULT_TUNNEL_DOMAIN = 'ngrok.debugg.ai';
|
|
62
52
|
const logger = new Logger({ module: 'tunnelManager' });
|
|
63
53
|
// ── Session identity (§2.1) ────────────────────────────────────────────────────
|
|
64
54
|
/**
|
|
@@ -112,7 +102,6 @@ class TunnelManager {
|
|
|
112
102
|
* session key join one creation instead of each minting their own (§2.3's
|
|
113
103
|
* cold-start TOCTOU fix — see ensureSessionTunnel()). */
|
|
114
104
|
pendingSessionTunnels = new Map();
|
|
115
|
-
initialized = false;
|
|
116
105
|
/**
|
|
117
106
|
* Idle window before a tunnel auto-shuts-off. Public so timer tests can run
|
|
118
107
|
* in milliseconds instead of 55 minutes, matching the `connectBackoffMs` /
|
|
@@ -125,29 +114,51 @@ class TunnelManager {
|
|
|
125
114
|
* changing the public API or depending on jest fake timers.
|
|
126
115
|
*/
|
|
127
116
|
connectBackoffMs = [500, 1500];
|
|
117
|
+
/**
|
|
118
|
+
* The transport implementations, keyed by the kind a provision response can
|
|
119
|
+
* select. Public and mutable in the same spirit as `caddyFactory`: a test
|
|
120
|
+
* drives a fake transport instead of a real agent or websocket.
|
|
121
|
+
*
|
|
122
|
+
* A provision response with no `transport` means ngrok, so an old backend
|
|
123
|
+
* and a new client keep working together.
|
|
124
|
+
*/
|
|
125
|
+
transports = {
|
|
126
|
+
ngrok: createNgrokTransport(),
|
|
127
|
+
debugg: createDebuggTransport({ clientVersion: `debugg-ai-mcp/${config.server.version}` }),
|
|
128
|
+
};
|
|
128
129
|
/**
|
|
129
130
|
* Bead pqgj: how the ngrok agent gets started + how we learn its client
|
|
130
131
|
* session is live. Overridable so tests can drive a fake agent instead of
|
|
131
132
|
* spawning a real ngrok process.
|
|
133
|
+
*
|
|
134
|
+
* The pre-warm itself moved into ngrokTransport (it is ngrok-only, and the
|
|
135
|
+
* debugg path must never touch the `ngrok` package), but the seam stays here
|
|
136
|
+
* because it is public API the existing suites drive.
|
|
132
137
|
*/
|
|
133
|
-
agentSessionStarter
|
|
138
|
+
get agentSessionStarter() {
|
|
139
|
+
return this.transports.ngrok.agentSessionStarter;
|
|
140
|
+
}
|
|
141
|
+
set agentSessionStarter(starter) {
|
|
142
|
+
this.transports.ngrok.agentSessionStarter = starter;
|
|
143
|
+
}
|
|
134
144
|
/**
|
|
135
145
|
* Cap on waiting for "client session established". Measured live at ~293ms;
|
|
136
146
|
* this is a generous ceiling, not an expected wait. On expiry we tunnel
|
|
137
147
|
* anyway and let the retry ladder handle it — a slow session must not become
|
|
138
148
|
* a hang.
|
|
139
149
|
*/
|
|
140
|
-
agentSessionTimeoutMs
|
|
150
|
+
get agentSessionTimeoutMs() {
|
|
151
|
+
return this.transports.ngrok.agentSessionTimeoutMs;
|
|
152
|
+
}
|
|
153
|
+
set agentSessionTimeoutMs(ms) {
|
|
154
|
+
this.transports.ngrok.agentSessionTimeoutMs = ms;
|
|
155
|
+
}
|
|
141
156
|
/**
|
|
142
157
|
* §2.2/§2.3: one fresh `CaddyProxy` instance PER SESSION KEY, never a
|
|
143
158
|
* process-wide singleton. Overridable so tests drive a fake proxy instead
|
|
144
159
|
* of spawning a real `caddy` process.
|
|
145
160
|
*/
|
|
146
161
|
caddyFactory = createCaddyProxy;
|
|
147
|
-
/** Whether the ngrok agent's client session is established (bead pqgj). */
|
|
148
|
-
agentSessionReady = false;
|
|
149
|
-
/** In-flight session bootstrap, so concurrent tunnels wait on one spawn. */
|
|
150
|
-
agentSessionPromise = null;
|
|
151
162
|
constructor(reg = getDefaultRegistry()) {
|
|
152
163
|
this.reg = reg;
|
|
153
164
|
// Bead `mdp`: sweep dead-owner entries on startup so the (now purely
|
|
@@ -182,7 +193,7 @@ class TunnelManager {
|
|
|
182
193
|
* so whichever call runs its synchronous prefix first wins the map slot,
|
|
183
194
|
* and the other necessarily observes it on its own synchronous prefix.
|
|
184
195
|
*/
|
|
185
|
-
async ensureSessionTunnel(sessionKey, authToken, specificTunnelId, keyId, revokeKey) {
|
|
196
|
+
async ensureSessionTunnel(sessionKey, authToken, specificTunnelId, keyId, revokeKey, selection) {
|
|
186
197
|
// 1. Fast path: a fully-created tunnel already exists for this session key.
|
|
187
198
|
const existingId = this.sessionTunnels.get(sessionKey);
|
|
188
199
|
if (existingId) {
|
|
@@ -198,7 +209,7 @@ class TunnelManager {
|
|
|
198
209
|
if (inFlight)
|
|
199
210
|
return inFlight;
|
|
200
211
|
// 3. First caller for this session key: claim the slot BEFORE any await.
|
|
201
|
-
const creation = this.createSessionTunnel(sessionKey, authToken, specificTunnelId, keyId, revokeKey)
|
|
212
|
+
const creation = this.createSessionTunnel(sessionKey, authToken, specificTunnelId, keyId, revokeKey, selection)
|
|
202
213
|
.finally(() => { this.pendingSessionTunnels.delete(sessionKey); });
|
|
203
214
|
this.pendingSessionTunnels.set(sessionKey, creation);
|
|
204
215
|
return creation;
|
|
@@ -234,14 +245,19 @@ class TunnelManager {
|
|
|
234
245
|
* A session that calls both a Caddy-routed tool AND run_test_suite pays for
|
|
235
246
|
* 2 tunnels for that session — honest and bounded, flagged in §6.
|
|
236
247
|
*/
|
|
237
|
-
async acquireDedicatedTunnel(url, authToken, keyId, revokeKey) {
|
|
238
|
-
await this.ensureInitialized();
|
|
248
|
+
async acquireDedicatedTunnel(url, authToken, keyId, revokeKey, selection) {
|
|
239
249
|
const port = extractLocalhostPort(url);
|
|
240
250
|
if (!port) {
|
|
241
251
|
throw new Error(`acquireDedicatedTunnel: could not extract port from localhost URL: ${url}`);
|
|
242
252
|
}
|
|
243
|
-
const
|
|
244
|
-
|
|
253
|
+
const transportKind = selection?.transport ?? 'ngrok';
|
|
254
|
+
// The ngrok path mints its own id, as it always has — an ngrok key is not
|
|
255
|
+
// bound to a hostname. A debugg token IS bound to its Tunnel record, so a
|
|
256
|
+
// client-minted uuid would be rejected with 401 and the id the backend
|
|
257
|
+
// issued must be used instead.
|
|
258
|
+
const tunnelId = selection?.tunnelId ?? uuidv4();
|
|
259
|
+
const domain = selection?.tunnelDomain ?? DEFAULT_TUNNEL_DOMAIN;
|
|
260
|
+
const tunnelDomain = `${tunnelId}.${domain}`;
|
|
245
261
|
const isHttpsLocal = url.startsWith('https:');
|
|
246
262
|
const inDocker = isDockerEnv();
|
|
247
263
|
// NOTE: this intentionally does NOT reuse caddyProxy.ts's
|
|
@@ -270,18 +286,28 @@ class TunnelManager {
|
|
|
270
286
|
const trace = new TunnelTrace();
|
|
271
287
|
trace.emit('acquireDedicatedTunnel.start', { port, tunnelId, hasFaultMode: !!faultMode });
|
|
272
288
|
try {
|
|
273
|
-
const tunnelUrl = await this.connectWithRetry(localAddr, tunnelDomain, authToken, trace, faults
|
|
289
|
+
const tunnelUrl = await this.connectWithRetry(localAddr, tunnelDomain, authToken, trace, faults, {
|
|
290
|
+
transport: transportKind,
|
|
291
|
+
tunnelId,
|
|
292
|
+
relayUrl: selection?.relayUrl,
|
|
293
|
+
onDead: (reason) => {
|
|
294
|
+
logger.error(`Dedicated tunnel ${tunnelId} reported dead by its transport (${reason}) — evicting`);
|
|
295
|
+
void this.stopTunnel(tunnelId);
|
|
296
|
+
},
|
|
297
|
+
});
|
|
274
298
|
const now = Date.now();
|
|
275
299
|
const info = {
|
|
276
|
-
tunnelId, tunnelUrl, createdAt: now, lastAccessedAt: now, keyId, revokeKey,
|
|
300
|
+
tunnelId, transport: transportKind, tunnelUrl, createdAt: now, lastAccessedAt: now, keyId, revokeKey,
|
|
277
301
|
};
|
|
278
302
|
this.directTunnels.set(tunnelId, info);
|
|
279
303
|
this.writeRegistryEntry(tunnelId, `dedicated:${tunnelId}`, tunnelUrl, -1);
|
|
280
304
|
this.armIdleTimer(info);
|
|
281
305
|
trace.emit('acquireDedicatedTunnel.success', { tunnelId, tunnelUrl });
|
|
282
306
|
logger.info(`Dedicated tunnel created: ${tunnelUrl} -> localhost:${port}`);
|
|
283
|
-
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, {
|
|
284
|
-
|
|
307
|
+
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, {
|
|
308
|
+
tunnelId, how: 'created-dedicated', transport: transportKind,
|
|
309
|
+
});
|
|
310
|
+
return { url: generateTunnelUrl(url, tunnelId, domain), tunnelId };
|
|
285
311
|
}
|
|
286
312
|
catch (error) {
|
|
287
313
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
@@ -356,12 +382,12 @@ class TunnelManager {
|
|
|
356
382
|
this.touchTunnel(tunnelId);
|
|
357
383
|
}
|
|
358
384
|
}
|
|
385
|
+
/** True for a hostname on ANY known tunnel domain (utils/tunnelDomains.ts). */
|
|
359
386
|
isTunnelUrl(url) {
|
|
360
|
-
return url
|
|
387
|
+
return isTunnelHost(url);
|
|
361
388
|
}
|
|
362
389
|
extractTunnelId(url) {
|
|
363
|
-
|
|
364
|
-
return match ? match[1] : null;
|
|
390
|
+
return extractTunnelIdFromHost(url);
|
|
365
391
|
}
|
|
366
392
|
getTunnelStatus(tunnelId) {
|
|
367
393
|
const tunnel = this.activeTunnels.get(tunnelId);
|
|
@@ -385,10 +411,12 @@ class TunnelManager {
|
|
|
385
411
|
return statuses;
|
|
386
412
|
}
|
|
387
413
|
// ── Session tunnel creation ─────────────────────────────────────────────────
|
|
388
|
-
async createSessionTunnel(sessionKey, authToken, specificTunnelId, keyId, revokeKey) {
|
|
389
|
-
|
|
414
|
+
async createSessionTunnel(sessionKey, authToken, specificTunnelId, keyId, revokeKey, selection) {
|
|
415
|
+
const transportKind = selection?.transport ?? 'ngrok';
|
|
390
416
|
const tunnelId = specificTunnelId ?? uuidv4();
|
|
391
|
-
|
|
417
|
+
// The hostname comes from the provision response for a debugg tunnel, and
|
|
418
|
+
// is ngrok's for everything else — minted ONCE per session either way.
|
|
419
|
+
const tunnelDomain = `${tunnelId}.${selection?.tunnelDomain ?? DEFAULT_TUNNEL_DOMAIN}`;
|
|
392
420
|
const caddy = this.caddyFactory(); // NEW instance per session key — never a process-wide singleton
|
|
393
421
|
const { localOrigin, adminPort } = await caddy.ensureStarted();
|
|
394
422
|
logger.info(`Creating session tunnel (domain: ${tunnelDomain}, session: ${sessionKey})`);
|
|
@@ -403,7 +431,18 @@ class TunnelManager {
|
|
|
403
431
|
// no HTTPS/Docker complexity on this leg at all (Caddy runs in the same
|
|
404
432
|
// host/container as the MCP server). That matrix moved entirely into
|
|
405
433
|
// caddyProxy.setUpstream(), invoked per-dispatch, not per-tunnel-creation.
|
|
406
|
-
const tunnelUrl = await this.connectWithRetry(localOrigin, tunnelDomain, authToken, trace, faults
|
|
434
|
+
const tunnelUrl = await this.connectWithRetry(localOrigin, tunnelDomain, authToken, trace, faults, {
|
|
435
|
+
transport: transportKind,
|
|
436
|
+
tunnelId,
|
|
437
|
+
relayUrl: selection?.relayUrl,
|
|
438
|
+
// A transport that reports the tunnel permanently gone (revoked, or
|
|
439
|
+
// auth refused on reconnect) must not leave a corpse behind for the
|
|
440
|
+
// next call to reuse: evict it exactly like a port change does.
|
|
441
|
+
onDead: (reason) => {
|
|
442
|
+
logger.error(`Tunnel ${tunnelId} reported dead by its transport (${reason}) — evicting`);
|
|
443
|
+
void this.stopTunnel(tunnelId);
|
|
444
|
+
},
|
|
445
|
+
});
|
|
407
446
|
const now = Date.now();
|
|
408
447
|
const portLock = new PortLock((t) => caddy.setUpstream(t));
|
|
409
448
|
caddy.onPortChanged(() => {
|
|
@@ -414,11 +453,13 @@ class TunnelManager {
|
|
|
414
453
|
// call. stopTunnel() never throws (its unconditional-removal
|
|
415
454
|
// contract, above), so this needs no defensive .catch() of its own.
|
|
416
455
|
logger.error(`Caddy proxy port changed under session ${sessionKey} — evicting tunnel ${tunnelId}`);
|
|
417
|
-
Telemetry.capture(TelemetryEvents.TUNNEL_EVICTED_PORT_CHANGED, {
|
|
456
|
+
Telemetry.capture(TelemetryEvents.TUNNEL_EVICTED_PORT_CHANGED, {
|
|
457
|
+
tunnelId, sessionKey, transport: transportKind,
|
|
458
|
+
});
|
|
418
459
|
void this.stopTunnel(tunnelId);
|
|
419
460
|
});
|
|
420
461
|
const info = {
|
|
421
|
-
tunnelId, sessionKey, tunnelUrl, createdAt: now, lastAccessedAt: now,
|
|
462
|
+
tunnelId, sessionKey, transport: transportKind, tunnelUrl, createdAt: now, lastAccessedAt: now,
|
|
422
463
|
keyId, revokeKey, caddy, portLock,
|
|
423
464
|
};
|
|
424
465
|
this.activeTunnels.set(tunnelId, info);
|
|
@@ -427,7 +468,7 @@ class TunnelManager {
|
|
|
427
468
|
this.armIdleTimer(info);
|
|
428
469
|
trace.emit('createSessionTunnel.success', { tunnelId, tunnelUrl });
|
|
429
470
|
logger.info(`Session tunnel created: ${tunnelUrl} (session ${sessionKey})`);
|
|
430
|
-
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { tunnelId, how: 'created' });
|
|
471
|
+
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { tunnelId, how: 'created', transport: transportKind });
|
|
431
472
|
return info;
|
|
432
473
|
}
|
|
433
474
|
catch (error) {
|
|
@@ -462,21 +503,29 @@ class TunnelManager {
|
|
|
462
503
|
* IDENTICAL retry/backoff/fault-injection/agent-prewarm behavior, so it
|
|
463
504
|
* lives here once.
|
|
464
505
|
*/
|
|
465
|
-
async connectWithRetry(localAddr, tunnelDomain, authToken, trace, faults) {
|
|
506
|
+
async connectWithRetry(localAddr, tunnelDomain, authToken, trace, faults, connectSpec) {
|
|
466
507
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
467
508
|
const BACKOFF_MS = this.connectBackoffMs; // bead ixh: test-overridable
|
|
468
509
|
const MAX_ATTEMPTS = BACKOFF_MS.length + 1; // N sleeps between N+1 attempts
|
|
510
|
+
const transport = this.transports[connectSpec.transport];
|
|
511
|
+
if (!transport) {
|
|
512
|
+
throw new Error(`No tunnel transport registered for "${connectSpec.transport}"`);
|
|
513
|
+
}
|
|
514
|
+
// Telemetry stage: the ngrok path keeps its existing value so dashboards
|
|
515
|
+
// built on it stay continuous.
|
|
516
|
+
const connectStage = transport.kind === 'ngrok' ? 'ngrok_connect' : 'debugg_connect';
|
|
469
517
|
const connectOpts = {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
authtoken: authToken,
|
|
518
|
+
tunnelId: connectSpec.tunnelId,
|
|
519
|
+
relayUrl: connectSpec.relayUrl,
|
|
520
|
+
onDead: connectSpec.onDead,
|
|
474
521
|
};
|
|
475
|
-
// Bead pqgj: pre-warm the agent session so attempt 1 doesn't race the
|
|
522
|
+
// Bead pqgj: pre-warm the agent session so attempt 1 doesn't race the ngrok
|
|
476
523
|
// agent's ~293ms not-ready window (which poisons the tunnel name via
|
|
477
524
|
// ngrok's own name-reusing internal retry and surfaces as
|
|
478
|
-
// "invalid tunnel configuration").
|
|
479
|
-
|
|
525
|
+
// "invalid tunnel configuration"). Transport-specific and a no-op for
|
|
526
|
+
// debugg, which has no agent to warm.
|
|
527
|
+
await transport.prepare?.(authToken);
|
|
528
|
+
trace.emit('transport.prepared', { transport: transport.kind });
|
|
480
529
|
let lastError;
|
|
481
530
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
482
531
|
trace.emit('connect.attempt.start', { attempt });
|
|
@@ -487,24 +536,26 @@ class TunnelManager {
|
|
|
487
536
|
await sleep(delayMs);
|
|
488
537
|
}
|
|
489
538
|
try {
|
|
490
|
-
|
|
491
|
-
//
|
|
492
|
-
// can simulate connect-layer failures without hitting the real API.
|
|
539
|
+
// Fault-inject a synthetic failure BEFORE the transport runs so we can
|
|
540
|
+
// simulate connect-layer failures without hitting a real API.
|
|
493
541
|
if (faults.shouldFailConnect()) {
|
|
494
542
|
trace.emit('connect.fault.inject', { attempt, mode: 'fail-connect-N' });
|
|
495
543
|
throw new Error(`[fault-inject] synthetic connect failure (attempt ${attempt})`);
|
|
496
544
|
}
|
|
497
|
-
const url = faults.shouldReturnEmptyUrl()
|
|
545
|
+
const url = faults.shouldReturnEmptyUrl()
|
|
546
|
+
? ''
|
|
547
|
+
: await transport.connect(localAddr, tunnelDomain, authToken, connectOpts);
|
|
498
548
|
if (!url) {
|
|
499
549
|
trace.emit('connect.attempt.empty-url', { attempt });
|
|
500
|
-
throw new Error(
|
|
550
|
+
throw new Error(`${transport.kind} transport returned empty URL (attempt ${attempt})`);
|
|
501
551
|
}
|
|
502
552
|
trace.emit('connect.attempt.success', { attempt });
|
|
503
553
|
if (attempt > 1) {
|
|
504
554
|
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISION_RETRY, {
|
|
505
555
|
attempt,
|
|
506
556
|
outcome: 'success',
|
|
507
|
-
stage:
|
|
557
|
+
stage: connectStage,
|
|
558
|
+
transport: transport.kind,
|
|
508
559
|
});
|
|
509
560
|
}
|
|
510
561
|
return url;
|
|
@@ -513,15 +564,21 @@ class TunnelManager {
|
|
|
513
564
|
lastError = err;
|
|
514
565
|
const msg = err instanceof Error ? err.message : String(err);
|
|
515
566
|
trace.emit('connect.attempt.fail', { attempt, message: msg.slice(0, 200) });
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
|
|
519
|
-
|
|
567
|
+
// Non-retryable by construction: a transport says so for the cases
|
|
568
|
+
// where looping cannot help (401 bad/expired key, 400 bad id, 426 this
|
|
569
|
+
// build is too old). Retrying those sends the identical request.
|
|
570
|
+
const declaredTerminal = err?.retryable === false;
|
|
571
|
+
// Auth-class errors are non-retryable for the same reason. Kept as a
|
|
572
|
+
// message test as well, because ngrok only ever reports them that way.
|
|
573
|
+
if (declaredTerminal || /authtoken|unauthorized|\b401\b|\b403\b/i.test(msg)) {
|
|
574
|
+
const reason = declaredTerminal ? 'transport-terminal' : 'auth-error';
|
|
575
|
+
trace.emit('connect.giving-up', { reason });
|
|
520
576
|
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISION_RETRY, {
|
|
521
577
|
attempt,
|
|
522
578
|
outcome: 'giving-up',
|
|
523
|
-
stage:
|
|
524
|
-
|
|
579
|
+
stage: connectStage,
|
|
580
|
+
transport: transport.kind,
|
|
581
|
+
reason,
|
|
525
582
|
});
|
|
526
583
|
throw err;
|
|
527
584
|
}
|
|
@@ -529,24 +586,24 @@ class TunnelManager {
|
|
|
529
586
|
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISION_RETRY, {
|
|
530
587
|
attempt,
|
|
531
588
|
outcome: isLastAttempt ? 'giving-up' : 'will-retry',
|
|
532
|
-
stage:
|
|
589
|
+
stage: connectStage,
|
|
590
|
+
transport: transport.kind,
|
|
533
591
|
});
|
|
534
592
|
if (isLastAttempt) {
|
|
535
593
|
trace.emit('connect.giving-up', { reason: 'max-attempts' });
|
|
536
594
|
throw err;
|
|
537
595
|
}
|
|
538
|
-
// Between attempt 1→2,
|
|
539
|
-
//
|
|
540
|
-
//
|
|
596
|
+
// Between attempt 1→2, let the transport reset itself — for ngrok that
|
|
597
|
+
// is the agent reset that covers the "agent died" failure mode, which
|
|
598
|
+
// used to be the only retried case. Between 2→3, just wait: the reset
|
|
599
|
+
// already happened. A transport with nothing to reset does nothing.
|
|
541
600
|
if (attempt === 1) {
|
|
542
|
-
logger.warn(
|
|
601
|
+
logger.warn(`${transport.kind} connect failed (attempt 1/${MAX_ATTEMPTS}), resetting transport: ${msg}`);
|
|
543
602
|
trace.emit('agent.reset');
|
|
544
|
-
|
|
545
|
-
this.initialized = false;
|
|
546
|
-
await this.ensureInitialized();
|
|
603
|
+
await transport.resetAfterFailedAttempt?.();
|
|
547
604
|
}
|
|
548
605
|
else {
|
|
549
|
-
logger.warn(
|
|
606
|
+
logger.warn(`${transport.kind} connect failed (attempt ${attempt}/${MAX_ATTEMPTS}), will retry: ${msg}`);
|
|
550
607
|
}
|
|
551
608
|
const backoffMs = BACKOFF_MS[attempt - 1] ?? BACKOFF_MS[BACKOFF_MS.length - 1];
|
|
552
609
|
trace.emit('connect.backoff', { attempt, backoffMs });
|
|
@@ -569,7 +626,7 @@ class TunnelManager {
|
|
|
569
626
|
clearTimeout(info.autoShutoffTimer);
|
|
570
627
|
this.removeRegistryEntry(info.tunnelId);
|
|
571
628
|
const results = await Promise.allSettled([
|
|
572
|
-
this.
|
|
629
|
+
this.transports[info.transport].disconnect(info.tunnelUrl),
|
|
573
630
|
info.caddy.stop(),
|
|
574
631
|
info.revokeKey ? info.revokeKey() : Promise.resolve(),
|
|
575
632
|
]);
|
|
@@ -581,9 +638,11 @@ class TunnelManager {
|
|
|
581
638
|
Telemetry.capture(TelemetryEvents.TUNNEL_TEARDOWN_PARTIAL_FAILURE, { tunnelId: info.tunnelId, step: i });
|
|
582
639
|
}
|
|
583
640
|
});
|
|
584
|
-
this.
|
|
641
|
+
this.notifyIfNoTunnelsRemain(info.transport);
|
|
585
642
|
logger.info(`Cleaned up session tunnel: ${info.tunnelUrl}`);
|
|
586
|
-
Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, {
|
|
643
|
+
Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, {
|
|
644
|
+
tunnelId: info.tunnelId, reason: 'stopped', transport: info.transport,
|
|
645
|
+
});
|
|
587
646
|
}
|
|
588
647
|
async stopDirectTunnel(info) {
|
|
589
648
|
// Same unconditional-removal contract as stopSessionTunnel, above.
|
|
@@ -592,7 +651,7 @@ class TunnelManager {
|
|
|
592
651
|
clearTimeout(info.autoShutoffTimer);
|
|
593
652
|
this.removeRegistryEntry(info.tunnelId);
|
|
594
653
|
const results = await Promise.allSettled([
|
|
595
|
-
this.
|
|
654
|
+
this.transports[info.transport].disconnect(info.tunnelUrl),
|
|
596
655
|
info.revokeKey ? info.revokeKey() : Promise.resolve(),
|
|
597
656
|
]);
|
|
598
657
|
results.forEach((r, i) => {
|
|
@@ -601,23 +660,28 @@ class TunnelManager {
|
|
|
601
660
|
Telemetry.capture(TelemetryEvents.TUNNEL_TEARDOWN_PARTIAL_FAILURE, { tunnelId: info.tunnelId, step: i });
|
|
602
661
|
}
|
|
603
662
|
});
|
|
604
|
-
this.
|
|
663
|
+
this.notifyIfNoTunnelsRemain(info.transport);
|
|
605
664
|
logger.info(`Cleaned up dedicated tunnel: ${info.tunnelUrl}`);
|
|
606
|
-
Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
const ngrok = await getNgrok();
|
|
610
|
-
await ngrok.disconnect(tunnelUrl);
|
|
665
|
+
Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, {
|
|
666
|
+
tunnelId: info.tunnelId, reason: 'stopped', transport: info.transport,
|
|
667
|
+
});
|
|
611
668
|
}
|
|
612
|
-
/**
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
669
|
+
/**
|
|
670
|
+
* Tell a transport when the last tunnel OF ITS KIND is gone. ngrok uses it to
|
|
671
|
+
* reset its module and init state, because the agent process may have exited
|
|
672
|
+
* with it; debugg has nothing process-wide to reset.
|
|
673
|
+
*
|
|
674
|
+
* Counted per transport, not globally: while both are in play, a debugg
|
|
675
|
+
* tunnel shutting down must not make the ngrok transport throw away a live
|
|
676
|
+
* agent that another tunnel is still using.
|
|
677
|
+
*/
|
|
678
|
+
notifyIfNoTunnelsRemain(transport) {
|
|
679
|
+
const remaining = [...this.activeTunnels.values(), ...this.directTunnels.values()]
|
|
680
|
+
.some((t) => t.transport === transport);
|
|
681
|
+
if (remaining)
|
|
682
|
+
return;
|
|
683
|
+
logger.info(`No ${transport} tunnels remain — notifying its transport`);
|
|
684
|
+
this.transports[transport].onAllTunnelsStopped?.();
|
|
621
685
|
}
|
|
622
686
|
// ── Idle timer (KEPT — minus the retired cross-process extension branch) ───
|
|
623
687
|
/**
|
|
@@ -681,74 +745,6 @@ class TunnelManager {
|
|
|
681
745
|
// best-effort
|
|
682
746
|
}
|
|
683
747
|
}
|
|
684
|
-
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
685
|
-
/**
|
|
686
|
-
* Bead pqgj: make sure the ngrok agent's client session is established before
|
|
687
|
-
* we ask it for a tunnel, so attempt 1 lands on a ready agent instead of the
|
|
688
|
-
* ~293ms not-ready window that made every single run fail its first connect.
|
|
689
|
-
*
|
|
690
|
-
* Never throws: if the agent can't be pre-warmed (ngrok internals moved, slow
|
|
691
|
-
* session, dead token) we fall through and let connectWithRetry's ladder do
|
|
692
|
-
* what it did before this fix. The ladder stays a genuine safety net.
|
|
693
|
-
*/
|
|
694
|
-
async ensureAgentSession(authtoken, trace) {
|
|
695
|
-
if (this.agentSessionReady)
|
|
696
|
-
return;
|
|
697
|
-
if (!this.agentSessionPromise) {
|
|
698
|
-
this.agentSessionPromise = (async () => {
|
|
699
|
-
let markEstablished;
|
|
700
|
-
const established = new Promise((resolve) => { markEstablished = resolve; });
|
|
701
|
-
await this.agentSessionStarter({
|
|
702
|
-
authtoken,
|
|
703
|
-
onStatusChange: (status) => {
|
|
704
|
-
if (status === 'connected') {
|
|
705
|
-
this.agentSessionReady = true;
|
|
706
|
-
markEstablished();
|
|
707
|
-
}
|
|
708
|
-
else if (status === 'closed') {
|
|
709
|
-
this.agentSessionReady = false;
|
|
710
|
-
}
|
|
711
|
-
},
|
|
712
|
-
onTerminated: () => {
|
|
713
|
-
// Agent process died — next tunnel must re-warm.
|
|
714
|
-
this.agentSessionReady = false;
|
|
715
|
-
this.agentSessionPromise = null;
|
|
716
|
-
},
|
|
717
|
-
});
|
|
718
|
-
let capTimer;
|
|
719
|
-
const cap = new Promise((resolve) => {
|
|
720
|
-
capTimer = setTimeout(resolve, this.agentSessionTimeoutMs);
|
|
721
|
-
});
|
|
722
|
-
try {
|
|
723
|
-
await Promise.race([established, cap]);
|
|
724
|
-
}
|
|
725
|
-
finally {
|
|
726
|
-
if (capTimer)
|
|
727
|
-
clearTimeout(capTimer);
|
|
728
|
-
}
|
|
729
|
-
})().catch((err) => {
|
|
730
|
-
// Pre-warm unavailable — not fatal, the ladder covers it.
|
|
731
|
-
this.agentSessionPromise = null;
|
|
732
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
733
|
-
trace?.emit('agent.session.prewarm-failed', { message: msg.slice(0, 200) });
|
|
734
|
-
logger.debug(`ngrok agent pre-warm unavailable, relying on connect retry ladder: ${msg}`);
|
|
735
|
-
});
|
|
736
|
-
}
|
|
737
|
-
await this.agentSessionPromise;
|
|
738
|
-
trace?.emit('agent.session.ready', { ready: this.agentSessionReady });
|
|
739
|
-
}
|
|
740
|
-
async ensureInitialized() {
|
|
741
|
-
if (!this.initialized) {
|
|
742
|
-
try {
|
|
743
|
-
const ngrok = await getNgrok();
|
|
744
|
-
ngrok.getApi();
|
|
745
|
-
}
|
|
746
|
-
catch {
|
|
747
|
-
// ignore — let connect surface real errors
|
|
748
|
-
}
|
|
749
|
-
this.initialized = true;
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
748
|
}
|
|
753
749
|
const tunnelManager = new TunnelManager();
|
|
754
750
|
export { tunnelManager };
|