@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
package/README.md
CHANGED
|
@@ -13,9 +13,10 @@ AI-powered browser testing via the [Model Context Protocol](https://modelcontext
|
|
|
13
13
|
**Testing `http://localhost:...` URLs requires the `caddy` binary** — `check_app_in_browser`,
|
|
14
14
|
`probe_page`, and `trigger_crawl` tunnel localhost targets through a local Caddy reverse proxy.
|
|
15
15
|
This installs automatically: the `@radically-straightforward/caddy` npm dependency downloads a
|
|
16
|
-
pinned Caddy release for your platform during `npm install`/`npx
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
pinned Caddy release for your platform during `npm install`/`npx` — nothing to install yourself in
|
|
17
|
+
the normal case. It is now the **only** binary this package downloads; the tunnel client itself is
|
|
18
|
+
pure TypeScript — it replaced the `ngrok` package, which fetched the ngrok agent.
|
|
19
|
+
If that download never ran (`npm install --ignore-scripts`, an offline/air-gapped install), point `CADDY_BIN` at
|
|
19
20
|
your own install (`brew install caddy` / `apt install caddy` / see
|
|
20
21
|
[caddyserver.com/docs/install](https://caddyserver.com/docs/install)) — missing it surfaces as a
|
|
21
22
|
clear error on the first localhost-URL call, not a silent hang. Public-URL calls, every
|
|
@@ -63,7 +64,7 @@ The server exposes **8** tools: three **Browser** tools plus one **action-based*
|
|
|
63
64
|
|
|
64
65
|
#### `check_app_in_browser`
|
|
65
66
|
|
|
66
|
-
Runs an AI browser agent against your app. The agent navigates, interacts, and reports back with screenshots. Localhost URLs are auto-tunneled
|
|
67
|
+
Runs an AI browser agent against your app. The agent navigates, interacts, and reports back with screenshots. Localhost URLs are auto-tunneled through the debugg tunnel server.
|
|
67
68
|
|
|
68
69
|
| Parameter | Type | Description |
|
|
69
70
|
|-----------|------|-------------|
|
|
@@ -254,7 +255,8 @@ clients without resource support keep using the tools.
|
|
|
254
255
|
### Security invariants
|
|
255
256
|
|
|
256
257
|
- Passwords are write-only. They never appear in any response body from any tool.
|
|
257
|
-
- Tunnel URLs (`*.
|
|
258
|
+
- Tunnel URLs (`*.tunnel.debugg.ai`, and the retired `*.ngrok.debugg.ai` that historical runs still
|
|
259
|
+
reference) are stripped from all browser-agent responses, including agent-authored text.
|
|
258
260
|
- 404s from the backend surface as `isError: true` with `{error: 'NotFound', ...}`, never as thrown exceptions.
|
|
259
261
|
- Missing `DEBUGGAI_API_KEY` surfaces as a structured tool error on first invocation — the server still registers and lists tools normally.
|
|
260
262
|
|
|
@@ -341,11 +343,11 @@ flow against the advertised authorization server. The bearer is request-scoped
|
|
|
341
343
|
|
|
342
344
|
stdio installs need none of these.
|
|
343
345
|
|
|
344
|
-
**Multi-replica deployments (go/no-go before rollout):** tunnel state (the
|
|
346
|
+
**Multi-replica deployments (go/no-go before rollout):** tunnel state (the session tunnel,
|
|
345
347
|
its Caddy instance, and its port-route lock) is in-process, keyed per caller by a hash of the
|
|
346
348
|
bearer token — there is no cross-process coordination. Running several replicas behind a plain
|
|
347
349
|
round-robin load balancer means one caller's calls can land on different replicas and mint one
|
|
348
|
-
tunnel **per replica they hit** instead of one for the whole session (
|
|
350
|
+
tunnel **per replica they hit** instead of one for the whole session (bounded by
|
|
349
351
|
replica count, self-healing via the existing 55-minute idle auto-shutoff — never a cross-session
|
|
350
352
|
correctness bug, since any single tool call stays on one replica for its whole duration). To get
|
|
351
353
|
the intended "one tunnel per session" behavior on a multi-replica HTTP deployment, configure
|
|
@@ -106,7 +106,7 @@ export async function probePageHandler(input, context, rawProgressCallback) {
|
|
|
106
106
|
// Tunnel keys we provisioned this call (for cleanup if creation fails after key acquired).
|
|
107
107
|
// The provisions this call acquired, in target order. Provisions rather than
|
|
108
108
|
// bare key ids because revoking follows the transport the backend picked
|
|
109
|
-
// (
|
|
109
|
+
// (it routes to the tunnel's own revoke endpoint).
|
|
110
110
|
const acquiredProvisions = [];
|
|
111
111
|
// Progress budget: 1 pre-flight + 1 template + 1 execute + N per-target captures + 1 done
|
|
112
112
|
const TOTAL_STEPS = 3 + input.targets.length + 1;
|
|
@@ -257,7 +257,7 @@ export async function probePageHandler(input, context, rawProgressCallback) {
|
|
|
257
257
|
detail: {
|
|
258
258
|
code: health.code,
|
|
259
259
|
status: health.status,
|
|
260
|
-
|
|
260
|
+
tunnelErrorCode: health.tunnelErrorCode,
|
|
261
261
|
elapsedMs: health.elapsedMs,
|
|
262
262
|
},
|
|
263
263
|
};
|
|
@@ -6,7 +6,7 @@ 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, sanitizeResponseUrls } from '../utils/tunnelContext.js';
|
|
9
|
-
import { tunnelManager } from '../services/
|
|
9
|
+
import { tunnelManager } from '../services/tunnel/tunnelManager.js';
|
|
10
10
|
import { config } from '../config/index.js';
|
|
11
11
|
import { resolveProject, resolveTestSuite } from '../utils/resolveProject.js';
|
|
12
12
|
const logger = new Logger({ module: 'runTestSuiteHandler' });
|
|
@@ -68,7 +68,7 @@ export async function runTestSuiteHandler(input, _context) {
|
|
|
68
68
|
// and its PortLock would give this handler no real protection —
|
|
69
69
|
// the lock would release back to contention seconds after
|
|
70
70
|
// triggering a suite that goes on to use the port for much longer.
|
|
71
|
-
// So it gets its OWN tunnel, dialing
|
|
71
|
+
// So it gets its OWN tunnel, dialing the app directly, bypassing
|
|
72
72
|
// Caddy/PortLock entirely (never deduped/reused — always fresh).
|
|
73
73
|
let tunnel;
|
|
74
74
|
try {
|
|
@@ -88,7 +88,7 @@ export async function runTestSuiteHandler(input, _context) {
|
|
|
88
88
|
const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
|
|
89
89
|
return errorResp('TunnelCreationFailed', `Tunnel creation failed for ${input.targetUrl}. (Detail: ${msg})`);
|
|
90
90
|
}
|
|
91
|
-
// Health probe — catches
|
|
91
|
+
// Health probe — catches an upstream-refused tunnel and bind mismatches before
|
|
92
92
|
// the remote agent wastes steps trying to reach the server.
|
|
93
93
|
if (dedicated.url) {
|
|
94
94
|
const health = await probeTunnelHealth(dedicated.url);
|
|
@@ -104,7 +104,7 @@ export async function runTestSuiteHandler(input, _context) {
|
|
|
104
104
|
// revoking a live tunnel's credential would kill what we preserved.
|
|
105
105
|
// (On the eviction branch markTunnelDead already revokes it.)
|
|
106
106
|
tunnelId = dedicated.tunnelId;
|
|
107
|
-
return errorResp('TunnelTrafficBlocked', `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`, { code: health.code,
|
|
107
|
+
return errorResp('TunnelTrafficBlocked', `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`, { code: health.code, tunnelErrorCode: health.tunnelErrorCode, elapsedMs: health.elapsedMs });
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
effectiveTargetUrl = dedicated.url;
|
|
@@ -125,7 +125,7 @@ export async function runTestSuiteHandler(input, _context) {
|
|
|
125
125
|
// at all — safe only by accident of `result`'s narrow return type never
|
|
126
126
|
// having carried a tunnel hostname in practice. Add the same defensive
|
|
127
127
|
// pass every other handler already runs, so a future backend field that
|
|
128
|
-
// echoes back the (dedicated,
|
|
128
|
+
// echoes back the (dedicated, app-direct) tunnel URL can never leak it
|
|
129
129
|
// to a caller who only knows their own localhost address.
|
|
130
130
|
const sanitizedPayload = sanitizeCtx ? sanitizeResponseUrls(responsePayload, sanitizeCtx) : responsePayload;
|
|
131
131
|
return {
|
|
@@ -16,7 +16,7 @@ import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, acqui
|
|
|
16
16
|
import { randomUUID } from 'node:crypto';
|
|
17
17
|
import { detectRepoName } from '../utils/gitContext.js';
|
|
18
18
|
import { disposeUnhealthyTunnel } from '../utils/tunnelDisposition.js';
|
|
19
|
-
import { probeLocalPort, probeTunnelHealth,
|
|
19
|
+
import { probeLocalPort, probeTunnelHealth, extractTunnelErrorCode } from '../utils/localReachability.js';
|
|
20
20
|
import { extractLocalhostPort } from '../utils/urlParser.js';
|
|
21
21
|
import { getCachedTemplateUuid, getCachedProjectUuid, invalidateTemplateCache, invalidateProjectCache, } from '../utils/handlerCaches.js';
|
|
22
22
|
import { isTransientWorkflowError, transientReasonTag } from '../utils/transientErrors.js';
|
|
@@ -35,12 +35,12 @@ function getMaxTransientRetries() {
|
|
|
35
35
|
return 1;
|
|
36
36
|
return Math.min(n, 3);
|
|
37
37
|
}
|
|
38
|
-
// Bug z15n: scan run evidence for
|
|
38
|
+
// Bug z15n: scan run evidence for the tunnel server's interstitial marker. It is a
|
|
39
39
|
// stable, recognizable string the REMOTE BROWSER saw — positive evidence that it
|
|
40
40
|
// hit our tunnel's error page rather than the user's app. Non-string parts are
|
|
41
41
|
// serialized (the action trace is the usual carrier); a part we can't serialize
|
|
42
42
|
// is simply skipped — it is not evidence either way.
|
|
43
|
-
function
|
|
43
|
+
function findTunnelErrorMarker(parts) {
|
|
44
44
|
for (const part of parts) {
|
|
45
45
|
if (part === undefined || part === null || part === '')
|
|
46
46
|
continue;
|
|
@@ -51,7 +51,7 @@ function findNgrokErrorMarker(parts) {
|
|
|
51
51
|
catch {
|
|
52
52
|
continue;
|
|
53
53
|
}
|
|
54
|
-
const code =
|
|
54
|
+
const code = extractTunnelErrorCode(text);
|
|
55
55
|
if (code)
|
|
56
56
|
return code;
|
|
57
57
|
}
|
|
@@ -202,7 +202,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
202
202
|
// --- Tunnel: reuse existing or provision a fresh one ---
|
|
203
203
|
if (ctx.isLocalhost) {
|
|
204
204
|
// Bead 1om: pre-flight local port probe BEFORE committing to backend
|
|
205
|
-
// provision +
|
|
205
|
+
// provision + tunnel session. If the user's dev server isn't listening,
|
|
206
206
|
// fail in ~1.5s with a structured error instead of burning 5 minutes
|
|
207
207
|
// on a browser agent trying to reach a dead tunnel.
|
|
208
208
|
const localPort = extractLocalhostPort(ctx.originalUrl);
|
|
@@ -251,7 +251,13 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
251
251
|
}
|
|
252
252
|
provision = tunnel;
|
|
253
253
|
try {
|
|
254
|
-
ctx = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.
|
|
254
|
+
ctx = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.tunnels.revoke(tunnel),
|
|
255
|
+
// The provision IS the transport selection. Omitting it here is
|
|
256
|
+
// not a no-op: without it ensureTunnel gets no relayUrl or
|
|
257
|
+
// tunnelDomain at all, so the flagship tool cannot connect a
|
|
258
|
+
// tunnel the backend just issued. The other three
|
|
259
|
+
// handlers pass it; this one was missed.
|
|
260
|
+
tunnel);
|
|
255
261
|
}
|
|
256
262
|
catch (tunnelError) {
|
|
257
263
|
const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
|
|
@@ -281,7 +287,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
281
287
|
: undefined,
|
|
282
288
|
});
|
|
283
289
|
// Bead 1om: verify traffic actually flows through the tunnel. The
|
|
284
|
-
// tunnel can be established (
|
|
290
|
+
// tunnel can be established (connect returns OK) yet refuse
|
|
285
291
|
// to forward traffic — e.g., IPv4/IPv6 bind mismatch, or the dev
|
|
286
292
|
// server died between the pre-flight probe and here. Catch it now,
|
|
287
293
|
// in ~1s, not via a 5-minute browser-agent false-pass.
|
|
@@ -294,11 +300,11 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
294
300
|
detail: {
|
|
295
301
|
code: health.code,
|
|
296
302
|
status: health.status,
|
|
297
|
-
|
|
303
|
+
tunnelErrorCode: health.tunnelErrorCode,
|
|
298
304
|
elapsedMs: health.elapsedMs,
|
|
299
305
|
},
|
|
300
306
|
};
|
|
301
|
-
logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.
|
|
307
|
+
logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.tunnelErrorCode ?? ''} in ${health.elapsedMs}ms`);
|
|
302
308
|
// Evict ONLY on a code proving the endpoint is gone; every other
|
|
303
309
|
// failure keeps the tunnel we are already paying for, because a
|
|
304
310
|
// teardown+re-provision costs two billed hours and this probe cannot
|
|
@@ -702,7 +708,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
702
708
|
// --- Post-hoc tunnel reclassification (bugs z15n, 4bui) ---
|
|
703
709
|
// The pre-flight probe above proves the tunnel was alive when we handed it to
|
|
704
710
|
// the remote browser — it can still die mid-run. When it does, the browser
|
|
705
|
-
// lands on
|
|
711
|
+
// lands on the tunnel server's DEBUGG_TUNNEL_* interstitial and the backend, which only sees
|
|
706
712
|
// "the page didn't contain what I was asked about", returns a normal 'fail'
|
|
707
713
|
// whose reason blames the USER'S page for OUR dead tunnel (execution
|
|
708
714
|
// a8f07747: 217s, 7 steps, failureCategory 'fail').
|
|
@@ -710,7 +716,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
710
716
|
// THE MARKER IS REQUIRED (bug 4bui). Reclassifying overrides the backend's
|
|
711
717
|
// verdict, so it demands positive evidence of the claim we are actually
|
|
712
718
|
// making: that the remote browser hit OUR error page DURING THE RUN. Only
|
|
713
|
-
// the
|
|
719
|
+
// the DEBUGG_TUNNEL_* marker recorded BY the run is evidence of that.
|
|
714
720
|
//
|
|
715
721
|
// The re-probe is NOT such evidence and can no longer trigger this on its
|
|
716
722
|
// own. It answers a different question — "is the tunnel healthy NOW?" —
|
|
@@ -722,12 +728,12 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
722
728
|
// independently returned a FALSE NETWORK_ERROR on healthy servers ~1 in 5
|
|
723
729
|
// runs (bug k6yq), so the false positive was reachable with nothing
|
|
724
730
|
// whatsoever wrong. (k6yq is now fixed: the cause was not the suspected DNS
|
|
725
|
-
// race but
|
|
731
|
+
// race but a connection-level flake against a freshly created
|
|
726
732
|
// tunnel, which probeTunnelHealth now retries. The marker requirement
|
|
727
733
|
// stands on its own regardless — the probe never decides this.)
|
|
728
734
|
//
|
|
729
735
|
// Requiring the marker loses no coverage: a real mid-run death fires BOTH
|
|
730
|
-
// arms (live-confirmed — re-probe NETWORK_ERROR *and* marker
|
|
736
|
+
// arms (live-confirmed — re-probe NETWORK_ERROR *and* an endpoint-gone marker),
|
|
731
737
|
// so the marker alone still catches it. The probe is kept purely as
|
|
732
738
|
// CORROBORATION in `detail` — it tells the caller whether the tunnel is
|
|
733
739
|
// still down now or has since recovered. Per epic 56kd ("relay honestly,
|
|
@@ -735,7 +741,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
735
741
|
// during the run is the relay inventing a cause.
|
|
736
742
|
let tunnelFault;
|
|
737
743
|
if (verdict.outcome === 'fail' && ctx.isLocalhost && ctx.tunnelId && ctx.targetUrl) {
|
|
738
|
-
const marker =
|
|
744
|
+
const marker = findTunnelErrorMarker([
|
|
739
745
|
verdict.reason,
|
|
740
746
|
finalExecution.state?.error,
|
|
741
747
|
finalExecution.errorMessage,
|
|
@@ -746,18 +752,18 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
746
752
|
// never decides whether we reclassify, only what we report alongside it.
|
|
747
753
|
const probe = await probeTunnelHealth(ctx.targetUrl).catch(() => undefined);
|
|
748
754
|
if (marker) {
|
|
749
|
-
tunnelFault = { probe,
|
|
755
|
+
tunnelFault = { probe, tunnelErrorCode: marker };
|
|
750
756
|
logger.warn(`Reclassifying backend 'fail' as an infrastructure fault for ${executionUuid}: ` +
|
|
751
757
|
`the run recorded ${marker} (re-probe now: ` +
|
|
752
758
|
`${probe ? (probe.healthy ? 'healthy — tunnel has since recovered' : probe.code) : 'unavailable'})`);
|
|
753
759
|
}
|
|
754
760
|
else if (probe && !probe.healthy) {
|
|
755
761
|
// Deliberately NOT reclassifying. The tunnel looks unhealthy now, but
|
|
756
|
-
// the run recorded no
|
|
762
|
+
// the run recorded no tunnel interstitial, so we have no evidence the
|
|
757
763
|
// browser ever saw one — the tunnel most likely died after the run (or
|
|
758
764
|
// the probe flaked). Relay the backend's verdict and log the tension.
|
|
759
765
|
logger.info(`Post-run tunnel re-probe for ${executionUuid} was unhealthy (${probe.code}) but the run ` +
|
|
760
|
-
'recorded no
|
|
766
|
+
'recorded no DEBUGG_TUNNEL_* marker, so the browser reached the app during the run. Relaying ' +
|
|
761
767
|
"the backend's verdict verbatim rather than blaming the tunnel.");
|
|
762
768
|
}
|
|
763
769
|
}
|
|
@@ -823,23 +829,23 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
823
829
|
// The backend's original verdict is preserved verbatim, never swallowed.
|
|
824
830
|
//
|
|
825
831
|
// Bug 4bui: the stated cause leads with the MARKER, because the marker is the
|
|
826
|
-
// evidence that justified getting here — the run itself recorded
|
|
832
|
+
// evidence that justified getting here — the run itself recorded the tunnel's
|
|
827
833
|
// interstitial. The probe only corroborates (and may legitimately say the
|
|
828
834
|
// tunnel has recovered by now), so it rides in `detail`, never as the cause.
|
|
829
835
|
if (tunnelFault) {
|
|
830
|
-
const { probe,
|
|
836
|
+
const { probe, tunnelErrorCode } = tunnelFault;
|
|
831
837
|
responsePayload.backendVerdict = { outcome: verdict.outcome, reason: verdict.reason };
|
|
832
838
|
responsePayload.outcome = 'error';
|
|
833
839
|
responsePayload.success = false;
|
|
834
840
|
responsePayload.failureCategory = 'infrastructure';
|
|
835
841
|
responsePayload.error = 'TunnelOfflineDuringRun';
|
|
836
842
|
responsePayload.message =
|
|
837
|
-
'During this run the remote browser landed on our tunnel\'s
|
|
843
|
+
'During this run the remote browser landed on our tunnel\'s error page instead of your ' +
|
|
838
844
|
'app, so the check evaluated our error page rather than your UI. This is an infrastructure ' +
|
|
839
845
|
'fault on our side, not a failed check — retry it. The backend\'s original verdict is ' +
|
|
840
846
|
'preserved under `backendVerdict`.';
|
|
841
847
|
responsePayload.reason =
|
|
842
|
-
`The run recorded
|
|
848
|
+
`The run recorded our tunnel's ${tunnelErrorCode} interstitial, so the remote browser reached ` +
|
|
843
849
|
'our tunnel error page instead of your app.' +
|
|
844
850
|
(probe
|
|
845
851
|
? probe.healthy
|
|
@@ -848,7 +854,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
848
854
|
`${probe.status ? ` (HTTP ${probe.status})` : ''}.)`
|
|
849
855
|
: '');
|
|
850
856
|
responsePayload.detail = {
|
|
851
|
-
|
|
857
|
+
tunnelErrorCode,
|
|
852
858
|
probeCode: probe?.code,
|
|
853
859
|
probeStatus: probe?.status,
|
|
854
860
|
probeHealthy: probe?.healthy,
|
|
@@ -874,7 +880,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
874
880
|
}
|
|
875
881
|
// Backend release 2026-04-25: browser_session block on execution detail
|
|
876
882
|
// carries presigned S3 URLs for HAR + console log + recording. Pass through
|
|
877
|
-
// verbatim — sanitizeResponseUrls below only strips
|
|
883
|
+
// verbatim — sanitizeResponseUrls below only strips tunnel hosts so S3 URLs
|
|
878
884
|
// are preserved. Resolves client-feedback items #1 (network) + #7 (console).
|
|
879
885
|
if (finalExecution.browserSession) {
|
|
880
886
|
responsePayload.browserSession = finalExecution.browserSession;
|
|
@@ -85,7 +85,7 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
85
85
|
try {
|
|
86
86
|
// --- Tunnel: reuse existing or provision a fresh one ---
|
|
87
87
|
if (ctx.isLocalhost) {
|
|
88
|
-
// Bead 1om: pre-flight local port probe BEFORE provision/
|
|
88
|
+
// Bead 1om: pre-flight local port probe BEFORE provision/tunnel/backend.
|
|
89
89
|
const localPort = extractLocalhostPort(ctx.originalUrl);
|
|
90
90
|
if (typeof localPort === 'number') {
|
|
91
91
|
const probe = await probeLocalPort(localPort);
|
|
@@ -155,11 +155,11 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
155
155
|
detail: {
|
|
156
156
|
code: health.code,
|
|
157
157
|
status: health.status,
|
|
158
|
-
|
|
158
|
+
tunnelErrorCode: health.tunnelErrorCode,
|
|
159
159
|
elapsedMs: health.elapsedMs,
|
|
160
160
|
},
|
|
161
161
|
};
|
|
162
|
-
logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.
|
|
162
|
+
logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.tunnelErrorCode ?? ''} in ${health.elapsedMs}ms`);
|
|
163
163
|
// Evict ONLY on a code proving the endpoint is gone; every other failure
|
|
164
164
|
// keeps the tunnel we are already paying for, because a teardown+
|
|
165
165
|
// re-provision costs two billed hours and this probe cannot tell a dead
|
package/dist/index.js
CHANGED
|
@@ -271,7 +271,7 @@ function safeLog(level, message, meta) {
|
|
|
271
271
|
*/
|
|
272
272
|
async function gracefulShutdown(signal) {
|
|
273
273
|
safeLog('info', `Received ${signal}, shutting down gracefully`);
|
|
274
|
-
const { tunnelManager } = await import('./services/
|
|
274
|
+
const { tunnelManager } = await import('./services/tunnel/tunnelManager.js');
|
|
275
275
|
await tunnelManager.stopAllTunnels().catch((err) => safeLog('warn', 'stopAllTunnels failed during shutdown', { error: String(err) }));
|
|
276
276
|
await Telemetry.shutdown();
|
|
277
277
|
process.exit(0);
|
|
@@ -83,8 +83,9 @@ export function isDockerEnv() {
|
|
|
83
83
|
// @radically-straightforward/caddy is a dependency (package.json's "caddy"
|
|
84
84
|
// field pins the exact version — see its postinstall) that downloads Caddy
|
|
85
85
|
// from the project's own GitHub releases and drops it at
|
|
86
|
-
// node_modules/.bin/caddy(.exe).
|
|
87
|
-
//
|
|
86
|
+
// node_modules/.bin/caddy(.exe). It is now the ONLY binary this package pulls
|
|
87
|
+
// down at install time — the ngrok package, whose postinstall fetched the ngrok
|
|
88
|
+
// agent the same way, is gone. Pinned rather
|
|
88
89
|
// than "latest" deliberately: a real config incompatibility with a Caddy
|
|
89
90
|
// version (--adapter json rejected by 2.11.3) was found and fixed during
|
|
90
91
|
// development of this file — "latest" silently shipping a breaking change
|
|
@@ -273,7 +274,7 @@ export function buildPatchBody(target, inDocker) {
|
|
|
273
274
|
upstreams: [{ dial }],
|
|
274
275
|
};
|
|
275
276
|
if (isHttpsLocal) {
|
|
276
|
-
// Local self-signed certs — doesn't touch the public leg's real
|
|
277
|
+
// Local self-signed certs — doesn't touch the public leg's real TLS.
|
|
277
278
|
body.transport = { protocol: 'http', tls: { insecure_skip_verify: true } };
|
|
278
279
|
}
|
|
279
280
|
return body;
|
|
@@ -370,7 +371,7 @@ export class CaddyProxyManager {
|
|
|
370
371
|
}
|
|
371
372
|
// Network-level failure against a previously-healthy admin port —
|
|
372
373
|
// the process is presumed dead. Exactly one respawn-and-retry, then
|
|
373
|
-
// propagate uncaught — no loop (matches
|
|
374
|
+
// propagate uncaught — no loop (matches the transport's
|
|
374
375
|
// onTerminated philosophy: lazy, bounded, next-call-triggered).
|
|
375
376
|
logger.warn(`Caddy admin API unreachable on port ${adminPort} — assuming process died, respawning once: ${err}`);
|
|
376
377
|
this.markDead();
|
|
@@ -520,7 +521,7 @@ export class CaddyProxyManager {
|
|
|
520
521
|
* successfully-bound proxy port (if any); attempt 2 falls back to a fresh
|
|
521
522
|
* one. `CaddyPortReclaimError` means BOTH attempts failed — Caddy is fully
|
|
522
523
|
* down. `onPortChanged` fires on the "succeeded but moved" case, which is
|
|
523
|
-
* the signal TunnelManager needs to evict the now-orphaned
|
|
524
|
+
* the signal TunnelManager needs to evict the now-orphaned tunnel.
|
|
524
525
|
*/
|
|
525
526
|
async doStart() {
|
|
526
527
|
sweepOrphanedConfigs(this.configDir);
|
|
@@ -593,7 +594,7 @@ export class CaddyProxyManager {
|
|
|
593
594
|
}
|
|
594
595
|
}
|
|
595
596
|
/** Restart policy: lazy, bounded, next-call-triggered — no background
|
|
596
|
-
* watchdog (matches
|
|
597
|
+
* watchdog (matches the transport's onDead philosophy). This
|
|
597
598
|
* handler only marks state as dead; it never itself triggers a respawn. */
|
|
598
599
|
installExitHandler(child) {
|
|
599
600
|
child.once('exit', (code, signal) => {
|
package/dist/services/index.js
CHANGED
|
@@ -473,15 +473,6 @@ export class DebuggAIServerClient {
|
|
|
473
473
|
isActive: c.isActive,
|
|
474
474
|
};
|
|
475
475
|
}
|
|
476
|
-
/**
|
|
477
|
-
* Revoke an ngrok API key by its key ID.
|
|
478
|
-
* Call this after workflow execution completes to clean up the short-lived key.
|
|
479
|
-
*/
|
|
480
|
-
async revokeNgrokKey(ngrokKeyId) {
|
|
481
|
-
if (!this.tx)
|
|
482
|
-
throw new Error('Client not initialized — call init() first');
|
|
483
|
-
await this.tx.post('api/v1/ngrok/revoke/', { ngrokKeyId });
|
|
484
|
-
}
|
|
485
476
|
// ── E2E Suite Management ──────────────────────────────────────────────────
|
|
486
477
|
async createTestSuite(input) {
|
|
487
478
|
if (!this.tx)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* One websocket per tunnel to the relayUrl a provision response supplies,
|
|
5
5
|
* speaking the wire protocol in services/tunnel/protocol/ (spec: bead
|
|
6
|
-
* debugg_ai_mcp-xkoh.1.2). It
|
|
6
|
+
* debugg_ai_mcp-xkoh.1.2). It replaced the `ngrok` package, which downloaded and
|
|
7
7
|
* spawns a native agent; `ws` is the only new dependency and is pure JS.
|
|
8
8
|
*
|
|
9
9
|
* This file owns exactly four things — the socket, the reconnect policy, the
|
|
@@ -324,7 +324,7 @@ class DebuggTransport {
|
|
|
324
324
|
});
|
|
325
325
|
socket.on('error', () => {
|
|
326
326
|
if (!established) {
|
|
327
|
-
// The
|
|
327
|
+
// The tunnel is fine; the app refused the connection.
|
|
328
328
|
// The server renders DEBUGG_TUNNEL_UPSTREAM_REFUSED from this code and
|
|
329
329
|
// tunnelDisposition keeps the tunnel.
|
|
330
330
|
stream.reset(ErrorCode.UPSTREAM_UNREACHABLE);
|
|
@@ -359,9 +359,9 @@ function terminal(message) {
|
|
|
359
359
|
function handshakeFailureError(state, status, supported, retryable) {
|
|
360
360
|
switch (status) {
|
|
361
361
|
case 401:
|
|
362
|
-
// The word "authtoken" is load-bearing: TunnelManager
|
|
363
|
-
// render its "invalid auth token"
|
|
364
|
-
//
|
|
362
|
+
// The word "authtoken" is load-bearing: TunnelManager's connect ladder
|
|
363
|
+
// matches it to stop retrying and render its "invalid auth token"
|
|
364
|
+
// message.
|
|
365
365
|
return terminal('the debugg tunnel server rejected the authtoken (HTTP 401) — the tunnel key is invalid, ' +
|
|
366
366
|
'expired or revoked. A new one is provisioned on the next call.');
|
|
367
367
|
case 400:
|
|
@@ -379,10 +379,10 @@ function handshakeFailureError(state, status, supported, retryable) {
|
|
|
379
379
|
}
|
|
380
380
|
}
|
|
381
381
|
/**
|
|
382
|
-
* A connection-level failure reaching the relay. This
|
|
383
|
-
* the
|
|
384
|
-
*
|
|
385
|
-
*
|
|
382
|
+
* A connection-level failure reaching the relay. This client does NOT honour
|
|
383
|
+
* HTTPS_PROXY (the retired ngrok agent did, which is the one capability lost
|
|
384
|
+
* in the cutover — design §5 "open gap"), so the message says plainly what has
|
|
385
|
+
* to be allowed instead of reading as a generic tunnel error.
|
|
386
386
|
*/
|
|
387
387
|
function egressError(state, detail, code) {
|
|
388
388
|
const relayHost = hostOf(state.relayUrl);
|
|
@@ -163,14 +163,14 @@ export const HandshakeError = Object.freeze({
|
|
|
163
163
|
});
|
|
164
164
|
// ── Server error markers (server emits, client parses) ───────────────────────
|
|
165
165
|
export const TunnelErrorMarker = Object.freeze({
|
|
166
|
-
/** No client connected — the
|
|
166
|
+
/** No client connected — 404, matching what the retired ngrok edge served. */
|
|
167
167
|
OFFLINE: 'DEBUGG_TUNNEL_OFFLINE',
|
|
168
168
|
/** Unknown or revoked tunnel id. */
|
|
169
169
|
UNKNOWN: 'DEBUGG_TUNNEL_UNKNOWN',
|
|
170
|
-
/** The client could not dial the local app —
|
|
170
|
+
/** The client could not dial the local app — 502. */
|
|
171
171
|
UPSTREAM_REFUSED: 'DEBUGG_TUNNEL_UPSTREAM_REFUSED',
|
|
172
172
|
});
|
|
173
|
-
/** HTTP status each marker is served with, chosen to match the
|
|
173
|
+
/** HTTP status each marker is served with, chosen to match the codes they replaced. */
|
|
174
174
|
export const MARKER_HTTP_STATUS = Object.freeze({
|
|
175
175
|
[TunnelErrorMarker.OFFLINE]: 404,
|
|
176
176
|
[TunnelErrorMarker.UNKNOWN]: 404,
|
|
@@ -1,15 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TunnelTransport — the
|
|
3
|
-
*
|
|
2
|
+
* TunnelTransport — the boundary between tunnel LIFECYCLE (tunnelManager.ts:
|
|
3
|
+
* who owns a tunnel, when it is reused, when it is torn down) and tunnel
|
|
4
|
+
* TRANSPORT (debuggTransport.ts: the websocket mux that actually carries
|
|
5
|
+
* bytes).
|
|
4
6
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
+
* This started life as a two-implementation seam so the ngrok client and the
|
|
8
|
+
* debugg client could run side by side through the rollout. ngrok is gone and
|
|
9
|
+
* there is exactly one implementation now, but the interface is KEPT and the
|
|
10
|
+
* two-implementation machinery around it is not — see the note below.
|
|
11
|
+
*
|
|
12
|
+
* WHY KEEP AN INTERFACE WITH ONE IMPLEMENTATION: it is the injection point
|
|
13
|
+
* (`tunnelManager.transport`) every tunnel-lifecycle test drives, in the same
|
|
14
|
+
* spirit as `caddyFactory`. Without it, testing eviction, the retry ladder or
|
|
15
|
+
* the idle timer means standing up a real websocket against a real relay.
|
|
16
|
+
* It also keeps debuggTransport's ~600 lines of framing, flow control and
|
|
17
|
+
* reconnect out of TunnelManager, which is the reason the file boundary is
|
|
18
|
+
* worth having regardless of how many implementations exist.
|
|
19
|
+
*
|
|
20
|
+
* WHAT WAS REMOVED WITH ngrok, because it was ceremony the moment the second
|
|
21
|
+
* implementation went away:
|
|
22
|
+
* - `TunnelTransportKind` and the `Record<kind, TunnelTransport>` registry —
|
|
23
|
+
* a map with one key, keyed by a union with one member.
|
|
24
|
+
* - `prepare()`, `resetAfterFailedAttempt()` and `onAllTunnelsStopped()` —
|
|
25
|
+
* three optional hooks that existed ONLY for the ngrok agent process
|
|
26
|
+
* (pre-warm, agent reset between retries, module reset when the agent may
|
|
27
|
+
* have exited). debugg has no out-of-process agent, so every one of them
|
|
28
|
+
* was a no-op on the only surviving implementation.
|
|
7
29
|
*
|
|
8
30
|
* Everything above this seam is unchanged: handlers, utils/tunnelContext.ts,
|
|
9
|
-
* the per-session Caddy instance, PortLock, URL rewriting, the retry ladder
|
|
10
|
-
* the idle timer
|
|
11
|
-
* (docs/debugg-tunnel-server-design-2026-09-19.md §5). TunnelManager picks an
|
|
12
|
-
* implementation from the provision response and calls it where it calls
|
|
13
|
-
* ngrok.connect() today.
|
|
31
|
+
* the per-session Caddy instance, PortLock, URL rewriting, the retry ladder and
|
|
32
|
+
* the idle timer (docs/debugg-tunnel-server-design-2026-09-19.md §5).
|
|
14
33
|
*/
|
|
15
34
|
export {};
|
|
@@ -6,27 +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>
|
|
9
|
+
* fail-connect-N:<count> — fail the first <count> transport 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
|
|
18
12
|
*
|
|
19
13
|
* Examples:
|
|
20
14
|
* DEBUGG_TUNNEL_FAULT_MODE=fail-connect-N:2
|
|
21
15
|
* DEBUGG_TUNNEL_FAULT_MODE=delay-connect:2000,fail-connect-N:1
|
|
22
|
-
*
|
|
16
|
+
*
|
|
17
|
+
* Two further modes — `inspector-unreachable` and `inspector-adopt` — went
|
|
18
|
+
* away with the ngrok agent inspector they drove. Both existed to exercise
|
|
19
|
+
* re-adoption of a tunnel already registered with the local ngrok agent, a
|
|
20
|
+
* mechanism that the per-session-tunnel cutover retired ("no sharing, ever")
|
|
21
|
+
* and that the debugg transport has no equivalent of: it owns its own socket
|
|
22
|
+
* and there is no out-of-process agent holding tunnels to discover.
|
|
23
23
|
*/
|
|
24
24
|
export function parseFaultMode(raw) {
|
|
25
25
|
if (!raw)
|
|
26
26
|
return null;
|
|
27
27
|
const mode = {};
|
|
28
28
|
for (const token of raw.split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
29
|
-
const m = token.match(/^(fail-connect-N|empty-url-N|delay-connect
|
|
29
|
+
const m = token.match(/^(fail-connect-N|empty-url-N|delay-connect):(\d+)$/);
|
|
30
30
|
if (!m)
|
|
31
31
|
continue;
|
|
32
32
|
const [, name, valStr] = m;
|
|
@@ -37,12 +37,6 @@ export function parseFaultMode(raw) {
|
|
|
37
37
|
mode.emptyUrlN = val;
|
|
38
38
|
else if (name === 'delay-connect')
|
|
39
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;
|
|
46
40
|
}
|
|
47
41
|
return Object.keys(mode).length > 0 ? mode : null;
|
|
48
42
|
}
|