@debugg-ai/debugg-ai-mcp 4.4.0 → 5.0.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 +9 -7
- package/dist/handlers/probePageHandler.js +2 -2
- package/dist/handlers/runTestSuiteHandler.js +5 -5
- package/dist/handlers/testPageChangesHandler.js +29 -23
- package/dist/handlers/triggerCrawlHandler.js +3 -3
- package/dist/index.js +1 -1
- package/dist/services/caddy/caddyProxy.js +7 -6
- package/dist/services/index.js +0 -9
- package/dist/services/tunnel/debuggTransport.js +9 -9
- package/dist/services/tunnel/protocol/constants.js +3 -3
- package/dist/services/tunnel/transport.js +28 -9
- package/dist/services/{ngrok → tunnel}/tunnelFaultInjection.js +9 -15
- package/dist/services/{ngrok → tunnel}/tunnelManager.js +65 -131
- package/dist/services/{ngrok → tunnel}/tunnelRegistry.js +2 -2
- package/dist/services/tunnels.js +64 -46
- package/dist/tools/testPageChanges.js +1 -1
- package/dist/utils/imageUtils.js +2 -1
- package/dist/utils/localReachability.js +62 -341
- package/dist/utils/telemetry.js +4 -4
- package/dist/utils/tunnelContext.js +11 -9
- package/dist/utils/tunnelDisposition.js +51 -69
- package/dist/utils/tunnelDomains.js +30 -21
- package/dist/utils/urlParser.js +5 -6
- package/package.json +2 -3
- package/dist/services/ngrok/error.js +0 -3
- package/dist/services/ngrok/ngrokAgentInspector.js +0 -210
- package/dist/services/ngrok/ngrokAgentSession.js +0 -49
- package/dist/services/ngrok/types.js +0 -1
- package/dist/services/tunnel/ngrokTransport.js +0 -165
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tunnel Management Service
|
|
3
3
|
*
|
|
4
|
-
* ONE
|
|
4
|
+
* ONE tunnel per SESSION KEY (§2.1 of
|
|
5
5
|
* docs/local-tunnel-multiplexer-architecture-2026-07-31.md) — not one per
|
|
6
6
|
* local port. A session's tunnel dials a local Caddy instance
|
|
7
7
|
* (services/caddy/caddyProxy.ts) that holds exactly one dynamic upstream,
|
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
*
|
|
12
12
|
* This retires the entire cross-process "borrow another MCP's tunnel"
|
|
13
13
|
* mechanism the previous per-port design needed (registry-mediated adoption,
|
|
14
|
-
* PID-liveness/freshness checks, re-adoption from
|
|
14
|
+
* PID-liveness/freshness checks, re-adoption from a local agent) —
|
|
15
15
|
* see §4's "Same-machine multi-process / multi-session sharing" decision:
|
|
16
16
|
* "No sharing, ever, at any granularity." Every tunnel this process holds is
|
|
17
|
-
* one it created; `services/
|
|
17
|
+
* one it created; `services/tunnel/tunnelRegistry.ts` is now write-mostly
|
|
18
18
|
* observability, not a correctness dependency.
|
|
19
19
|
*
|
|
20
20
|
* Session identity (§2.1): stdio has exactly one session key for its whole
|
|
@@ -40,15 +40,16 @@ import { FaultInjector, TunnelTrace, getFaultModeFromEnv } from './tunnelFaultIn
|
|
|
40
40
|
import { getDefaultRegistry, } from './tunnelRegistry.js';
|
|
41
41
|
import { createCaddyProxy, isDockerEnv, } from '../caddy/caddyProxy.js';
|
|
42
42
|
import { PortLock } from '../caddy/portLock.js';
|
|
43
|
-
import {
|
|
44
|
-
import { createDebuggTransport } from '../tunnel/debuggTransport.js';
|
|
43
|
+
import { createDebuggTransport } from './debuggTransport.js';
|
|
45
44
|
import { extractTunnelIdFromHost, isTunnelHost, } from '../../utils/tunnelDomains.js';
|
|
46
45
|
/**
|
|
47
46
|
* The hostname suffix a tunnel gets when the provision response does not name
|
|
48
|
-
* one
|
|
49
|
-
*
|
|
47
|
+
* one. The backend always names one, so this is a floor rather than a path
|
|
48
|
+
* anything is expected to take — but a tunnel with no hostname at all is
|
|
49
|
+
* unusable, and silently building one on a domain we do not own would be worse
|
|
50
|
+
* than building one here.
|
|
50
51
|
*/
|
|
51
|
-
const DEFAULT_TUNNEL_DOMAIN = '
|
|
52
|
+
const DEFAULT_TUNNEL_DOMAIN = 'tunnel.debugg.ai';
|
|
52
53
|
const logger = new Logger({ module: 'tunnelManager' });
|
|
53
54
|
// ── Session identity (§2.1) ────────────────────────────────────────────────────
|
|
54
55
|
/**
|
|
@@ -109,50 +110,22 @@ class TunnelManager {
|
|
|
109
110
|
*/
|
|
110
111
|
idleTimeoutMs = 55 * 60 * 1000;
|
|
111
112
|
/**
|
|
112
|
-
* Backoff schedule (ms) between
|
|
113
|
+
* Backoff schedule (ms) between transport connect() retry attempts. Bead ixh.
|
|
113
114
|
* Exposed on the class so tests can override with short delays without
|
|
114
115
|
* changing the public API or depending on jest fake timers.
|
|
115
116
|
*/
|
|
116
117
|
connectBackoffMs = [500, 1500];
|
|
117
118
|
/**
|
|
118
|
-
* The transport
|
|
119
|
-
*
|
|
120
|
-
*
|
|
119
|
+
* The tunnel transport. Public and mutable in the same spirit as
|
|
120
|
+
* `caddyFactory`: a test drives a fake transport instead of a real websocket
|
|
121
|
+
* to a real relay.
|
|
121
122
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
123
|
+
* This was a `Record<kind, TunnelTransport>` while the ngrok and debugg
|
|
124
|
+
* clients ran side by side. With ngrok deleted it is one field again — a map
|
|
125
|
+
* with a single key, keyed by a union with a single member, is bookkeeping
|
|
126
|
+
* for a choice nobody makes any more.
|
|
124
127
|
*/
|
|
125
|
-
|
|
126
|
-
ngrok: createNgrokTransport(),
|
|
127
|
-
debugg: createDebuggTransport({ clientVersion: `debugg-ai-mcp/${config.server.version}` }),
|
|
128
|
-
};
|
|
129
|
-
/**
|
|
130
|
-
* Bead pqgj: how the ngrok agent gets started + how we learn its client
|
|
131
|
-
* session is live. Overridable so tests can drive a fake agent instead of
|
|
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.
|
|
137
|
-
*/
|
|
138
|
-
get agentSessionStarter() {
|
|
139
|
-
return this.transports.ngrok.agentSessionStarter;
|
|
140
|
-
}
|
|
141
|
-
set agentSessionStarter(starter) {
|
|
142
|
-
this.transports.ngrok.agentSessionStarter = starter;
|
|
143
|
-
}
|
|
144
|
-
/**
|
|
145
|
-
* Cap on waiting for "client session established". Measured live at ~293ms;
|
|
146
|
-
* this is a generous ceiling, not an expected wait. On expiry we tunnel
|
|
147
|
-
* anyway and let the retry ladder handle it — a slow session must not become
|
|
148
|
-
* a hang.
|
|
149
|
-
*/
|
|
150
|
-
get agentSessionTimeoutMs() {
|
|
151
|
-
return this.transports.ngrok.agentSessionTimeoutMs;
|
|
152
|
-
}
|
|
153
|
-
set agentSessionTimeoutMs(ms) {
|
|
154
|
-
this.transports.ngrok.agentSessionTimeoutMs = ms;
|
|
155
|
-
}
|
|
128
|
+
transport = createDebuggTransport({ clientVersion: `debugg-ai-mcp/${config.server.version}` });
|
|
156
129
|
/**
|
|
157
130
|
* §2.2/§2.3: one fresh `CaddyProxy` instance PER SESSION KEY, never a
|
|
158
131
|
* process-wide singleton. Overridable so tests drive a fake proxy instead
|
|
@@ -183,7 +156,7 @@ class TunnelManager {
|
|
|
183
156
|
*
|
|
184
157
|
* §2.3's cold-start TOCTOU fix: the read (`sessionTunnels.get`) and the
|
|
185
158
|
* eventual write (`sessionTunnels.set`) are separated by several `await`
|
|
186
|
-
* points (spawning Caddy, connecting
|
|
159
|
+
* points (spawning Caddy, connecting the tunnel). Two near-simultaneous first
|
|
187
160
|
* calls for the same fresh session key — exactly what an orchestrating
|
|
188
161
|
* agent produces (an initial navigate fired alongside an initial probe) —
|
|
189
162
|
* would otherwise both observe a miss and each mint their own tunnel,
|
|
@@ -231,8 +204,8 @@ class TunnelManager {
|
|
|
231
204
|
// ── Public API — the run_test_suite exception (§2.3) ───────────────────────
|
|
232
205
|
/**
|
|
233
206
|
* Used ONLY by runTestSuiteHandler.ts. Bypasses Caddy/PortLock entirely —
|
|
234
|
-
* dials
|
|
235
|
-
* createTunnel(). Governed by the same idleTimeoutMs auto-shutoff as any
|
|
207
|
+
* the tunnel dials straight at the app, exactly like the pre-cutover
|
|
208
|
+
* per-port createTunnel(). Governed by the same idleTimeoutMs auto-shutoff as any
|
|
236
209
|
* other tunnel. This is a deliberate, scoped exception to "one tunnel per
|
|
237
210
|
* session" (§2.3) — not a smuggled-in legacy fallback — forced by
|
|
238
211
|
* run_test_suite's async execution model: it is fire-and-forget (no poll
|
|
@@ -250,11 +223,10 @@ class TunnelManager {
|
|
|
250
223
|
if (!port) {
|
|
251
224
|
throw new Error(`acquireDedicatedTunnel: could not extract port from localhost URL: ${url}`);
|
|
252
225
|
}
|
|
253
|
-
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
// issued must be used instead.
|
|
226
|
+
// The token IS bound to its Tunnel record, so a client-minted uuid is
|
|
227
|
+
// rejected with 401 and the id the backend issued must be used. The uuidv4()
|
|
228
|
+
// is a floor for callers that pass no provision at all (tests); it cannot
|
|
229
|
+
// authenticate against a real relay.
|
|
258
230
|
const tunnelId = selection?.tunnelId ?? uuidv4();
|
|
259
231
|
const domain = selection?.tunnelDomain ?? DEFAULT_TUNNEL_DOMAIN;
|
|
260
232
|
const tunnelDomain = `${tunnelId}.${domain}`;
|
|
@@ -263,14 +235,14 @@ class TunnelManager {
|
|
|
263
235
|
// NOTE: this intentionally does NOT reuse caddyProxy.ts's
|
|
264
236
|
// resolveDialAddress() — that function builds Caddy's JSON `dial` field,
|
|
265
237
|
// which is always a bare `host:port` (Caddy conveys TLS-ness via its
|
|
266
|
-
// separate `transport` field, never a URL scheme in `dial`).
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
// this path preserves byte-for-byte since it
|
|
271
|
-
// exactly like the pre-cutover per-port
|
|
272
|
-
// resolveDialAddress() here would silently drop
|
|
273
|
-
// HTTPS dedicated tunnels.
|
|
238
|
+
// separate `transport` field, never a URL scheme in `dial`). A transport's
|
|
239
|
+
// `connect(localAddr)` is a different consumer with a different format: it
|
|
240
|
+
// DOES need a `https://` scheme prefix for an HTTPS local target (see
|
|
241
|
+
// debuggTransport's parseLocalAddr, and the original
|
|
242
|
+
// tunnelManager.ts:696-701 this path preserves byte-for-byte since it
|
|
243
|
+
// dials the app directly, exactly like the pre-cutover per-port
|
|
244
|
+
// createTunnel()). Reusing resolveDialAddress() here would silently drop
|
|
245
|
+
// that scheme and break HTTPS dedicated tunnels.
|
|
274
246
|
const dockerHost = 'host.docker.internal';
|
|
275
247
|
let localAddr;
|
|
276
248
|
if (isHttpsLocal) {
|
|
@@ -287,7 +259,6 @@ class TunnelManager {
|
|
|
287
259
|
trace.emit('acquireDedicatedTunnel.start', { port, tunnelId, hasFaultMode: !!faultMode });
|
|
288
260
|
try {
|
|
289
261
|
const tunnelUrl = await this.connectWithRetry(localAddr, tunnelDomain, authToken, trace, faults, {
|
|
290
|
-
transport: transportKind,
|
|
291
262
|
tunnelId,
|
|
292
263
|
relayUrl: selection?.relayUrl,
|
|
293
264
|
onDead: (reason) => {
|
|
@@ -297,7 +268,7 @@ class TunnelManager {
|
|
|
297
268
|
});
|
|
298
269
|
const now = Date.now();
|
|
299
270
|
const info = {
|
|
300
|
-
tunnelId,
|
|
271
|
+
tunnelId, tunnelUrl, createdAt: now, lastAccessedAt: now, keyId, revokeKey,
|
|
301
272
|
};
|
|
302
273
|
this.directTunnels.set(tunnelId, info);
|
|
303
274
|
this.writeRegistryEntry(tunnelId, `dedicated:${tunnelId}`, tunnelUrl, -1);
|
|
@@ -305,7 +276,7 @@ class TunnelManager {
|
|
|
305
276
|
trace.emit('acquireDedicatedTunnel.success', { tunnelId, tunnelUrl });
|
|
306
277
|
logger.info(`Dedicated tunnel created: ${tunnelUrl} -> localhost:${port}`);
|
|
307
278
|
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, {
|
|
308
|
-
tunnelId, how: 'created-dedicated', transport:
|
|
279
|
+
tunnelId, how: 'created-dedicated', transport: this.transport.kind,
|
|
309
280
|
});
|
|
310
281
|
return { url: generateTunnelUrl(url, tunnelId, domain), tunnelId };
|
|
311
282
|
}
|
|
@@ -321,7 +292,7 @@ class TunnelManager {
|
|
|
321
292
|
}
|
|
322
293
|
// ── Public API — lifecycle / teardown ──────────────────────────────────────
|
|
323
294
|
/**
|
|
324
|
-
* Evict a tunnel that a health probe PROVED dead (e.g.
|
|
295
|
+
* Evict a tunnel that a health probe PROVED dead (e.g. DEBUGG_TUNNEL_UNKNOWN) —
|
|
325
296
|
* simplifies to a plain delegate now that every tunnel is created (never
|
|
326
297
|
* borrowed) by this process: there is no shared-registry adoption record
|
|
327
298
|
* to also evict (bead k34o's second half retired along with borrowing,
|
|
@@ -333,7 +304,7 @@ class TunnelManager {
|
|
|
333
304
|
/**
|
|
334
305
|
* `stopTunnel()`'s ordering is a load-bearing contract, not an
|
|
335
306
|
* implementation detail (§2.3): map removal is UNCONDITIONAL and happens
|
|
336
|
-
* before any cleanup I/O, so a downstream cleanup failure (
|
|
307
|
+
* before any cleanup I/O, so a downstream cleanup failure (tunnel
|
|
337
308
|
* disconnect, `caddy.stop()`, key revoke) can never leave a
|
|
338
309
|
* live-looking-but-actually-dead `TunnelInfo` behind for the next call to
|
|
339
310
|
* find — the exact failure mode the `onPortChanged`-triggered eviction
|
|
@@ -412,10 +383,8 @@ class TunnelManager {
|
|
|
412
383
|
}
|
|
413
384
|
// ── Session tunnel creation ─────────────────────────────────────────────────
|
|
414
385
|
async createSessionTunnel(sessionKey, authToken, specificTunnelId, keyId, revokeKey, selection) {
|
|
415
|
-
const transportKind = selection?.transport ?? 'ngrok';
|
|
416
386
|
const tunnelId = specificTunnelId ?? uuidv4();
|
|
417
|
-
// The hostname comes from the provision response
|
|
418
|
-
// is ngrok's for everything else — minted ONCE per session either way.
|
|
387
|
+
// The hostname comes from the provision response, minted ONCE per session.
|
|
419
388
|
const tunnelDomain = `${tunnelId}.${selection?.tunnelDomain ?? DEFAULT_TUNNEL_DOMAIN}`;
|
|
420
389
|
const caddy = this.caddyFactory(); // NEW instance per session key — never a process-wide singleton
|
|
421
390
|
const { localOrigin, adminPort } = await caddy.ensureStarted();
|
|
@@ -427,12 +396,11 @@ class TunnelManager {
|
|
|
427
396
|
const trace = new TunnelTrace();
|
|
428
397
|
trace.emit('createSessionTunnel.start', { tunnelId, sessionKey, hasFaultMode: !!faultMode });
|
|
429
398
|
try {
|
|
430
|
-
//
|
|
399
|
+
// The tunnel's own dial target is always plain loopback HTTP to Caddy —
|
|
431
400
|
// no HTTPS/Docker complexity on this leg at all (Caddy runs in the same
|
|
432
401
|
// host/container as the MCP server). That matrix moved entirely into
|
|
433
402
|
// caddyProxy.setUpstream(), invoked per-dispatch, not per-tunnel-creation.
|
|
434
403
|
const tunnelUrl = await this.connectWithRetry(localOrigin, tunnelDomain, authToken, trace, faults, {
|
|
435
|
-
transport: transportKind,
|
|
436
404
|
tunnelId,
|
|
437
405
|
relayUrl: selection?.relayUrl,
|
|
438
406
|
// A transport that reports the tunnel permanently gone (revoked, or
|
|
@@ -447,19 +415,19 @@ class TunnelManager {
|
|
|
447
415
|
const portLock = new PortLock((t) => caddy.setUpstream(t));
|
|
448
416
|
caddy.onPortChanged(() => {
|
|
449
417
|
// A crash-triggered respawn landed on a DIFFERENT local proxy port
|
|
450
|
-
// (sticky-port reclaim failed). The existing
|
|
418
|
+
// (sticky-port reclaim failed). The existing tunnel is now
|
|
451
419
|
// dialing a dead port — nothing Caddy-internal can fix this; the
|
|
452
420
|
// whole session tunnel must be torn down and recreated on the next
|
|
453
421
|
// call. stopTunnel() never throws (its unconditional-removal
|
|
454
422
|
// contract, above), so this needs no defensive .catch() of its own.
|
|
455
423
|
logger.error(`Caddy proxy port changed under session ${sessionKey} — evicting tunnel ${tunnelId}`);
|
|
456
424
|
Telemetry.capture(TelemetryEvents.TUNNEL_EVICTED_PORT_CHANGED, {
|
|
457
|
-
tunnelId, sessionKey, transport:
|
|
425
|
+
tunnelId, sessionKey, transport: this.transport.kind,
|
|
458
426
|
});
|
|
459
427
|
void this.stopTunnel(tunnelId);
|
|
460
428
|
});
|
|
461
429
|
const info = {
|
|
462
|
-
tunnelId, sessionKey,
|
|
430
|
+
tunnelId, sessionKey, tunnelUrl, createdAt: now, lastAccessedAt: now,
|
|
463
431
|
keyId, revokeKey, caddy, portLock,
|
|
464
432
|
};
|
|
465
433
|
this.activeTunnels.set(tunnelId, info);
|
|
@@ -468,7 +436,9 @@ class TunnelManager {
|
|
|
468
436
|
this.armIdleTimer(info);
|
|
469
437
|
trace.emit('createSessionTunnel.success', { tunnelId, tunnelUrl });
|
|
470
438
|
logger.info(`Session tunnel created: ${tunnelUrl} (session ${sessionKey})`);
|
|
471
|
-
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, {
|
|
439
|
+
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, {
|
|
440
|
+
tunnelId, how: 'created', transport: this.transport.kind,
|
|
441
|
+
});
|
|
472
442
|
return info;
|
|
473
443
|
}
|
|
474
444
|
catch (error) {
|
|
@@ -489,43 +459,36 @@ class TunnelManager {
|
|
|
489
459
|
}
|
|
490
460
|
// ── Shared connect-retry ladder (KEPT byte-for-byte — bead ixh/pqgj/42g/fhg) ─
|
|
491
461
|
/**
|
|
492
|
-
* Bead ixh: 3-attempt retry for
|
|
462
|
+
* Bead ixh: 3-attempt retry for transient connect failures.
|
|
493
463
|
* - Attempt 1: fresh connect
|
|
494
|
-
* - Attempt 2: after 500ms backoff,
|
|
495
|
-
*
|
|
496
|
-
* - Attempt 3: after 1500ms backoff, retry with the already-reset agent
|
|
464
|
+
* - Attempt 2: after 500ms backoff, retry
|
|
465
|
+
* - Attempt 3: after 1500ms backoff, retry
|
|
497
466
|
* Auth-token errors short-circuit at any attempt — no point looping.
|
|
498
467
|
*
|
|
468
|
+
* The ladder used to reset the ngrok agent module between attempts 1 and 2,
|
|
469
|
+
* because an out-of-process agent that had died was the failure this existed
|
|
470
|
+
* to recover from. The debugg transport has no out-of-process agent and owns
|
|
471
|
+
* its own socket lifecycle, so the ladder is now purely backoff-and-retry.
|
|
472
|
+
*
|
|
499
473
|
* Parameterized by `localAddr` rather than computing it internally: the
|
|
500
474
|
* session-tunnel path (createSessionTunnel) always dials Caddy's fixed
|
|
501
475
|
* local origin; the dedicated-tunnel path (acquireDedicatedTunnel) dials
|
|
502
476
|
* the app directly via the isHttpsLocal/inDocker matrix. Both need the
|
|
503
|
-
* IDENTICAL retry/backoff/fault-injection
|
|
504
|
-
* lives here once.
|
|
477
|
+
* IDENTICAL retry/backoff/fault-injection behavior, so it lives here once.
|
|
505
478
|
*/
|
|
506
479
|
async connectWithRetry(localAddr, tunnelDomain, authToken, trace, faults, connectSpec) {
|
|
507
480
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
508
481
|
const BACKOFF_MS = this.connectBackoffMs; // bead ixh: test-overridable
|
|
509
482
|
const MAX_ATTEMPTS = BACKOFF_MS.length + 1; // N sleeps between N+1 attempts
|
|
510
|
-
const transport = this.
|
|
511
|
-
|
|
512
|
-
|
|
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';
|
|
483
|
+
const transport = this.transport;
|
|
484
|
+
// Telemetry stage: kept as an explicit constant so dashboards built on the
|
|
485
|
+
// retry event's `stage` dimension stay continuous.
|
|
486
|
+
const connectStage = 'debugg_connect';
|
|
517
487
|
const connectOpts = {
|
|
518
488
|
tunnelId: connectSpec.tunnelId,
|
|
519
489
|
relayUrl: connectSpec.relayUrl,
|
|
520
490
|
onDead: connectSpec.onDead,
|
|
521
491
|
};
|
|
522
|
-
// Bead pqgj: pre-warm the agent session so attempt 1 doesn't race the ngrok
|
|
523
|
-
// agent's ~293ms not-ready window (which poisons the tunnel name via
|
|
524
|
-
// ngrok's own name-reusing internal retry and surfaces as
|
|
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 });
|
|
529
492
|
let lastError;
|
|
530
493
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
531
494
|
trace.emit('connect.attempt.start', { attempt });
|
|
@@ -569,7 +532,8 @@ class TunnelManager {
|
|
|
569
532
|
// build is too old). Retrying those sends the identical request.
|
|
570
533
|
const declaredTerminal = err?.retryable === false;
|
|
571
534
|
// Auth-class errors are non-retryable for the same reason. Kept as a
|
|
572
|
-
// message test as well
|
|
535
|
+
// message test as well as a flag, so an auth failure that reaches us
|
|
536
|
+
// as text rather than as `retryable: false` still stops the ladder.
|
|
573
537
|
if (declaredTerminal || /authtoken|unauthorized|\b401\b|\b403\b/i.test(msg)) {
|
|
574
538
|
const reason = declaredTerminal ? 'transport-terminal' : 'auth-error';
|
|
575
539
|
trace.emit('connect.giving-up', { reason });
|
|
@@ -593,18 +557,7 @@ class TunnelManager {
|
|
|
593
557
|
trace.emit('connect.giving-up', { reason: 'max-attempts' });
|
|
594
558
|
throw err;
|
|
595
559
|
}
|
|
596
|
-
|
|
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.
|
|
600
|
-
if (attempt === 1) {
|
|
601
|
-
logger.warn(`${transport.kind} connect failed (attempt 1/${MAX_ATTEMPTS}), resetting transport: ${msg}`);
|
|
602
|
-
trace.emit('agent.reset');
|
|
603
|
-
await transport.resetAfterFailedAttempt?.();
|
|
604
|
-
}
|
|
605
|
-
else {
|
|
606
|
-
logger.warn(`${transport.kind} connect failed (attempt ${attempt}/${MAX_ATTEMPTS}), will retry: ${msg}`);
|
|
607
|
-
}
|
|
560
|
+
logger.warn(`${transport.kind} connect failed (attempt ${attempt}/${MAX_ATTEMPTS}), will retry: ${msg}`);
|
|
608
561
|
const backoffMs = BACKOFF_MS[attempt - 1] ?? BACKOFF_MS[BACKOFF_MS.length - 1];
|
|
609
562
|
trace.emit('connect.backoff', { attempt, backoffMs });
|
|
610
563
|
await sleep(backoffMs);
|
|
@@ -626,7 +579,7 @@ class TunnelManager {
|
|
|
626
579
|
clearTimeout(info.autoShutoffTimer);
|
|
627
580
|
this.removeRegistryEntry(info.tunnelId);
|
|
628
581
|
const results = await Promise.allSettled([
|
|
629
|
-
this.
|
|
582
|
+
this.transport.disconnect(info.tunnelUrl),
|
|
630
583
|
info.caddy.stop(),
|
|
631
584
|
info.revokeKey ? info.revokeKey() : Promise.resolve(),
|
|
632
585
|
]);
|
|
@@ -638,10 +591,9 @@ class TunnelManager {
|
|
|
638
591
|
Telemetry.capture(TelemetryEvents.TUNNEL_TEARDOWN_PARTIAL_FAILURE, { tunnelId: info.tunnelId, step: i });
|
|
639
592
|
}
|
|
640
593
|
});
|
|
641
|
-
this.notifyIfNoTunnelsRemain(info.transport);
|
|
642
594
|
logger.info(`Cleaned up session tunnel: ${info.tunnelUrl}`);
|
|
643
595
|
Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, {
|
|
644
|
-
tunnelId: info.tunnelId, reason: 'stopped', transport:
|
|
596
|
+
tunnelId: info.tunnelId, reason: 'stopped', transport: this.transport.kind,
|
|
645
597
|
});
|
|
646
598
|
}
|
|
647
599
|
async stopDirectTunnel(info) {
|
|
@@ -651,7 +603,7 @@ class TunnelManager {
|
|
|
651
603
|
clearTimeout(info.autoShutoffTimer);
|
|
652
604
|
this.removeRegistryEntry(info.tunnelId);
|
|
653
605
|
const results = await Promise.allSettled([
|
|
654
|
-
this.
|
|
606
|
+
this.transport.disconnect(info.tunnelUrl),
|
|
655
607
|
info.revokeKey ? info.revokeKey() : Promise.resolve(),
|
|
656
608
|
]);
|
|
657
609
|
results.forEach((r, i) => {
|
|
@@ -660,29 +612,11 @@ class TunnelManager {
|
|
|
660
612
|
Telemetry.capture(TelemetryEvents.TUNNEL_TEARDOWN_PARTIAL_FAILURE, { tunnelId: info.tunnelId, step: i });
|
|
661
613
|
}
|
|
662
614
|
});
|
|
663
|
-
this.notifyIfNoTunnelsRemain(info.transport);
|
|
664
615
|
logger.info(`Cleaned up dedicated tunnel: ${info.tunnelUrl}`);
|
|
665
616
|
Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, {
|
|
666
|
-
tunnelId: info.tunnelId, reason: 'stopped', transport:
|
|
617
|
+
tunnelId: info.tunnelId, reason: 'stopped', transport: this.transport.kind,
|
|
667
618
|
});
|
|
668
619
|
}
|
|
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?.();
|
|
685
|
-
}
|
|
686
620
|
// ── Idle timer (KEPT — minus the retired cross-process extension branch) ───
|
|
687
621
|
/**
|
|
688
622
|
* Bead y7x6/lc62's cross-process "another process touched the registry
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
* discovered and BORROWED an existing tunnel instead of provisioning a
|
|
7
7
|
* duplicate for the same port. That borrowing mechanism (and everything that
|
|
8
8
|
* existed only to make it safe — freshness TTLs, PID-reuse defenses,
|
|
9
|
-
* adopt/reconcile against
|
|
9
|
+
* adopt/reconcile against a local tunnel agent) is retired outright by
|
|
10
10
|
* docs/local-tunnel-multiplexer-architecture-2026-07-31.md §4: "No sharing,
|
|
11
11
|
* ever, at any granularity. Each session key gets its own Caddy instance and
|
|
12
|
-
* its own
|
|
12
|
+
* its own tunnel."
|
|
13
13
|
*
|
|
14
14
|
* What remains is a diagnostic view: `RegistryEntry` rows, keyed by
|
|
15
15
|
* `tunnelId` (not port — a port no longer identifies anything unique once
|
package/dist/services/tunnels.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tunnels Service
|
|
3
|
-
* Provisions short-lived
|
|
3
|
+
* Provisions short-lived debugg tunnel tokens for MCP-managed tunnel setup.
|
|
4
4
|
* Called before executeWorkflow so the tunnel URL is known before execution starts.
|
|
5
5
|
*/
|
|
6
6
|
import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
|
|
7
7
|
import { isValidTunnelDomain, registerTunnelDomain, } from '../utils/tunnelDomains.js';
|
|
8
8
|
/**
|
|
9
|
-
* Typed error thrown by provision() when the backend
|
|
9
|
+
* Typed error thrown by provision() when the backend tunnel path fails.
|
|
10
10
|
* Carries diagnostic fields a retry wrapper (bead 7nx) can use to decide
|
|
11
11
|
* whether to retry, and that handler error messages can surface so users
|
|
12
12
|
* have something actionable to file bug reports against.
|
|
@@ -81,8 +81,16 @@ export function classifyProvisionError(err) {
|
|
|
81
81
|
}
|
|
82
82
|
const DEFAULT_BACKOFF_MS = [500, 1500, 3000];
|
|
83
83
|
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
84
|
-
/**
|
|
85
|
-
|
|
84
|
+
/**
|
|
85
|
+
* The transports this client can actually drive.
|
|
86
|
+
*
|
|
87
|
+
* KEEP SENDING THIS, even though it is now a list of one. The backend's
|
|
88
|
+
* negotiation treats a request that offers NOTHING as a pre-negotiation client
|
|
89
|
+
* and hands it ngrok (design §2), which this client can no longer speak. An
|
|
90
|
+
* empty or absent `transports` is therefore not a simplification — it is how
|
|
91
|
+
* you get handed a tunnel you cannot connect to.
|
|
92
|
+
*/
|
|
93
|
+
export const SUPPORTED_TRANSPORTS = Object.freeze(['debugg']);
|
|
86
94
|
/** A relay URL must be wss, except against a loopback backend (local dev, tests). */
|
|
87
95
|
function isAcceptableRelayUrl(relayUrl) {
|
|
88
96
|
let url;
|
|
@@ -114,61 +122,71 @@ export const createTunnelsService = (tx) => {
|
|
|
114
122
|
retryable: false,
|
|
115
123
|
});
|
|
116
124
|
}
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
125
|
+
// Every failure below is non-retryable: retrying cannot turn a response
|
|
126
|
+
// this client cannot use into one it can, and TunnelManager must never be
|
|
127
|
+
// handed a token with nowhere to send it.
|
|
128
|
+
//
|
|
129
|
+
// The backend still negotiates, so it can still answer "ngrok" — to a
|
|
130
|
+
// client that no longer has an ngrok transport to answer with. That is a
|
|
131
|
+
// version mismatch, and it gets its own message rather than a generic
|
|
132
|
+
// "unsupported transport", because it is the only thing the user will see
|
|
133
|
+
// and the fix is a specific one.
|
|
134
|
+
if (response.transport === 'ngrok') {
|
|
135
|
+
throw new TunnelProvisionError({
|
|
136
|
+
message: 'Tunnel provisioning selected the ngrok transport, which this version of ' +
|
|
137
|
+
'@debugg-ai/debugg-ai-mcp no longer implements (it offered: ' +
|
|
138
|
+
`${SUPPORTED_TRANSPORTS.join(', ')}). Either the backend has not been moved off ngrok yet, ` +
|
|
139
|
+
'or this account is still pinned to it. Pin @debugg-ai/debugg-ai-mcp@4.4.1 until the ' +
|
|
140
|
+
'backend serves debugg tunnels.',
|
|
141
|
+
retryable: false,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (response.transport !== undefined && !SUPPORTED_TRANSPORTS.includes(response.transport)) {
|
|
121
145
|
throw new TunnelProvisionError({
|
|
122
146
|
message: `Tunnel provisioning selected transport "${response.transport}", which this client cannot speak ` +
|
|
123
147
|
`(it offered ${SUPPORTED_TRANSPORTS.join(', ')}). Update @debugg-ai/debugg-ai-mcp.`,
|
|
124
148
|
retryable: false,
|
|
125
149
|
});
|
|
126
150
|
}
|
|
127
|
-
|
|
151
|
+
// A response with no `transport` at all predates negotiation entirely. It
|
|
152
|
+
// is only usable if it is self-describing — i.e. it carries the debugg
|
|
153
|
+
// fields anyway. Otherwise it is an ngrok provision in all but name.
|
|
154
|
+
if (!response.relayUrl || !response.tunnelDomain) {
|
|
155
|
+
throw new TunnelProvisionError({
|
|
156
|
+
message: 'Tunnel provisioning returned no relayUrl/tunnelDomain, so there is no debugg tunnel to ' +
|
|
157
|
+
'connect to. This backend predates the debugg tunnel server; pin ' +
|
|
158
|
+
'@debugg-ai/debugg-ai-mcp@4.4.1 until it is upgraded.',
|
|
159
|
+
retryable: false,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (!isAcceptableRelayUrl(response.relayUrl)) {
|
|
163
|
+
throw new TunnelProvisionError({
|
|
164
|
+
message: `Tunnel provisioning returned a relayUrl that is not wss (${response.relayUrl}); ` +
|
|
165
|
+
'refusing to send the tunnel key over it',
|
|
166
|
+
retryable: false,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (!isValidTunnelDomain(response.tunnelDomain)) {
|
|
170
|
+
throw new TunnelProvisionError({
|
|
171
|
+
message: `Tunnel provisioning returned an unusable tunnelDomain (${response.tunnelDomain})`,
|
|
172
|
+
retryable: false,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
// Registering it here is what stops a tunnel URL leaking: everything that
|
|
176
|
+
// recognises or rewrites a tunnel hostname reads this same registry, so a
|
|
177
|
+
// domain the backend moves to is covered the moment it is handed to us.
|
|
178
|
+
registerTunnelDomain(response.tunnelDomain);
|
|
179
|
+
return {
|
|
128
180
|
tunnelId: response.tunnelId,
|
|
129
181
|
tunnelKey: response.tunnelKey,
|
|
130
182
|
keyId: response.keyId,
|
|
131
183
|
expiresAt: response.expiresAt,
|
|
132
|
-
|
|
184
|
+
relayUrl: response.relayUrl,
|
|
185
|
+
tunnelDomain: response.tunnelDomain,
|
|
133
186
|
};
|
|
134
|
-
if (transport === 'debugg') {
|
|
135
|
-
// Every one of these is non-retryable: retrying cannot turn a malformed
|
|
136
|
-
// response into a usable one, and TunnelManager must never be handed a
|
|
137
|
-
// debugg token with nowhere to send it.
|
|
138
|
-
if (!response.relayUrl || !response.tunnelDomain) {
|
|
139
|
-
throw new TunnelProvisionError({
|
|
140
|
-
message: 'Tunnel provisioning selected the debugg transport but omitted relayUrl or tunnelDomain',
|
|
141
|
-
retryable: false,
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
if (!isAcceptableRelayUrl(response.relayUrl)) {
|
|
145
|
-
throw new TunnelProvisionError({
|
|
146
|
-
message: `Tunnel provisioning returned a relayUrl that is not wss (${response.relayUrl}); ` +
|
|
147
|
-
'refusing to send the tunnel key over it',
|
|
148
|
-
retryable: false,
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
if (!isValidTunnelDomain(response.tunnelDomain)) {
|
|
152
|
-
throw new TunnelProvisionError({
|
|
153
|
-
message: `Tunnel provisioning returned an unusable tunnelDomain (${response.tunnelDomain})`,
|
|
154
|
-
retryable: false,
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
// Registering it here is what stops a tunnel URL leaking: everything that
|
|
158
|
-
// recognises or rewrites a tunnel hostname reads this same registry, so a
|
|
159
|
-
// domain the backend moves to is covered the moment it is handed to us.
|
|
160
|
-
registerTunnelDomain(response.tunnelDomain, 'debugg');
|
|
161
|
-
result.relayUrl = response.relayUrl;
|
|
162
|
-
result.tunnelDomain = response.tunnelDomain;
|
|
163
|
-
}
|
|
164
|
-
return result;
|
|
165
187
|
}
|
|
166
188
|
async function revoke(provisionInfo) {
|
|
167
|
-
|
|
168
|
-
await tx.post(`api/v1/tunnels/${provisionInfo.tunnelId}/revoke/`, {});
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
await tx.post('api/v1/ngrok/revoke/', { ngrokKeyId: provisionInfo.keyId });
|
|
189
|
+
await tx.post(`api/v1/tunnels/${provisionInfo.tunnelId}/revoke/`, {});
|
|
172
190
|
}
|
|
173
191
|
async function provisionWithRetry(opts = {}) {
|
|
174
192
|
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
@@ -8,7 +8,7 @@ import { testPageChangesHandler } from '../handlers/testPageChangesHandler.js';
|
|
|
8
8
|
import { WRITES } from './annotations.js';
|
|
9
9
|
const BASE_DESCRIPTION = `Give an AI agent eyes on a live website or app. The agent browses it, interacts with it, and tells you whether a given task or check passed. Works on localhost or any URL. Use for visual QA, flow validation, regression checks, or anything that needs a real browser to verify.
|
|
10
10
|
|
|
11
|
-
LOCALHOST SUPPORT: Pass any localhost URL (e.g. http://localhost:3000) and it Just Works. A secure tunnel is automatically created so the remote browser can reach your local dev server — no manual
|
|
11
|
+
LOCALHOST SUPPORT: Pass any localhost URL (e.g. http://localhost:3000) and it Just Works. A secure tunnel is automatically created so the remote browser can reach your local dev server — no manual tunnel setup, no port forwarding, no config.
|
|
12
12
|
|
|
13
13
|
SCOPE PER CALL: Keep each call to ONE focused check — a single page or a short interaction on a single screen (login, submit a form, verify a heading). For anything spanning multiple pages or long multi-step flows, split into SEPARATE calls — the remote browser agent has a ~25-step internal budget per call, and long single calls risk client-side timeouts. Example: instead of "log in, then go to settings, then update profile, then verify," make three calls: (1) log in & verify dashboard, (2) update settings, (3) verify profile change.
|
|
14
14
|
|
package/dist/utils/imageUtils.js
CHANGED
|
@@ -73,7 +73,8 @@ function titleize(key) {
|
|
|
73
73
|
* Build resource_link blocks for every (presigned) artifact URL found one level
|
|
74
74
|
* deep in `source` (e.g. an execution's browserSession: HAR, console log, run
|
|
75
75
|
* recording). Defensive about exact field names — it links any https value and
|
|
76
|
-
* skips tunnel
|
|
76
|
+
* skips tunnel hosts (including historical `*.ngrok.debugg.ai` ones).
|
|
77
|
+
* Returns [] for nullish/empty input.
|
|
77
78
|
*/
|
|
78
79
|
export function artifactResourceLinks(source) {
|
|
79
80
|
if (!source || typeof source !== 'object')
|