@debugg-ai/debugg-ai-mcp 4.4.1 → 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 +25 -25
- 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);
|
|
@@ -253,9 +253,9 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
253
253
|
try {
|
|
254
254
|
ctx = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.tunnels.revoke(tunnel),
|
|
255
255
|
// The provision IS the transport selection. Omitting it here is
|
|
256
|
-
// not a no-op:
|
|
257
|
-
//
|
|
258
|
-
//
|
|
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
259
|
// handlers pass it; this one was missed.
|
|
260
260
|
tunnel);
|
|
261
261
|
}
|
|
@@ -287,7 +287,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
287
287
|
: undefined,
|
|
288
288
|
});
|
|
289
289
|
// Bead 1om: verify traffic actually flows through the tunnel. The
|
|
290
|
-
// tunnel can be established (
|
|
290
|
+
// tunnel can be established (connect returns OK) yet refuse
|
|
291
291
|
// to forward traffic — e.g., IPv4/IPv6 bind mismatch, or the dev
|
|
292
292
|
// server died between the pre-flight probe and here. Catch it now,
|
|
293
293
|
// in ~1s, not via a 5-minute browser-agent false-pass.
|
|
@@ -300,11 +300,11 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
300
300
|
detail: {
|
|
301
301
|
code: health.code,
|
|
302
302
|
status: health.status,
|
|
303
|
-
|
|
303
|
+
tunnelErrorCode: health.tunnelErrorCode,
|
|
304
304
|
elapsedMs: health.elapsedMs,
|
|
305
305
|
},
|
|
306
306
|
};
|
|
307
|
-
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`);
|
|
308
308
|
// Evict ONLY on a code proving the endpoint is gone; every other
|
|
309
309
|
// failure keeps the tunnel we are already paying for, because a
|
|
310
310
|
// teardown+re-provision costs two billed hours and this probe cannot
|
|
@@ -708,7 +708,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
708
708
|
// --- Post-hoc tunnel reclassification (bugs z15n, 4bui) ---
|
|
709
709
|
// The pre-flight probe above proves the tunnel was alive when we handed it to
|
|
710
710
|
// the remote browser — it can still die mid-run. When it does, the browser
|
|
711
|
-
// lands on
|
|
711
|
+
// lands on the tunnel server's DEBUGG_TUNNEL_* interstitial and the backend, which only sees
|
|
712
712
|
// "the page didn't contain what I was asked about", returns a normal 'fail'
|
|
713
713
|
// whose reason blames the USER'S page for OUR dead tunnel (execution
|
|
714
714
|
// a8f07747: 217s, 7 steps, failureCategory 'fail').
|
|
@@ -716,7 +716,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
716
716
|
// THE MARKER IS REQUIRED (bug 4bui). Reclassifying overrides the backend's
|
|
717
717
|
// verdict, so it demands positive evidence of the claim we are actually
|
|
718
718
|
// making: that the remote browser hit OUR error page DURING THE RUN. Only
|
|
719
|
-
// the
|
|
719
|
+
// the DEBUGG_TUNNEL_* marker recorded BY the run is evidence of that.
|
|
720
720
|
//
|
|
721
721
|
// The re-probe is NOT such evidence and can no longer trigger this on its
|
|
722
722
|
// own. It answers a different question — "is the tunnel healthy NOW?" —
|
|
@@ -728,12 +728,12 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
728
728
|
// independently returned a FALSE NETWORK_ERROR on healthy servers ~1 in 5
|
|
729
729
|
// runs (bug k6yq), so the false positive was reachable with nothing
|
|
730
730
|
// whatsoever wrong. (k6yq is now fixed: the cause was not the suspected DNS
|
|
731
|
-
// race but
|
|
731
|
+
// race but a connection-level flake against a freshly created
|
|
732
732
|
// tunnel, which probeTunnelHealth now retries. The marker requirement
|
|
733
733
|
// stands on its own regardless — the probe never decides this.)
|
|
734
734
|
//
|
|
735
735
|
// Requiring the marker loses no coverage: a real mid-run death fires BOTH
|
|
736
|
-
// arms (live-confirmed — re-probe NETWORK_ERROR *and* marker
|
|
736
|
+
// arms (live-confirmed — re-probe NETWORK_ERROR *and* an endpoint-gone marker),
|
|
737
737
|
// so the marker alone still catches it. The probe is kept purely as
|
|
738
738
|
// CORROBORATION in `detail` — it tells the caller whether the tunnel is
|
|
739
739
|
// still down now or has since recovered. Per epic 56kd ("relay honestly,
|
|
@@ -741,7 +741,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
741
741
|
// during the run is the relay inventing a cause.
|
|
742
742
|
let tunnelFault;
|
|
743
743
|
if (verdict.outcome === 'fail' && ctx.isLocalhost && ctx.tunnelId && ctx.targetUrl) {
|
|
744
|
-
const marker =
|
|
744
|
+
const marker = findTunnelErrorMarker([
|
|
745
745
|
verdict.reason,
|
|
746
746
|
finalExecution.state?.error,
|
|
747
747
|
finalExecution.errorMessage,
|
|
@@ -752,18 +752,18 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
752
752
|
// never decides whether we reclassify, only what we report alongside it.
|
|
753
753
|
const probe = await probeTunnelHealth(ctx.targetUrl).catch(() => undefined);
|
|
754
754
|
if (marker) {
|
|
755
|
-
tunnelFault = { probe,
|
|
755
|
+
tunnelFault = { probe, tunnelErrorCode: marker };
|
|
756
756
|
logger.warn(`Reclassifying backend 'fail' as an infrastructure fault for ${executionUuid}: ` +
|
|
757
757
|
`the run recorded ${marker} (re-probe now: ` +
|
|
758
758
|
`${probe ? (probe.healthy ? 'healthy — tunnel has since recovered' : probe.code) : 'unavailable'})`);
|
|
759
759
|
}
|
|
760
760
|
else if (probe && !probe.healthy) {
|
|
761
761
|
// Deliberately NOT reclassifying. The tunnel looks unhealthy now, but
|
|
762
|
-
// the run recorded no
|
|
762
|
+
// the run recorded no tunnel interstitial, so we have no evidence the
|
|
763
763
|
// browser ever saw one — the tunnel most likely died after the run (or
|
|
764
764
|
// the probe flaked). Relay the backend's verdict and log the tension.
|
|
765
765
|
logger.info(`Post-run tunnel re-probe for ${executionUuid} was unhealthy (${probe.code}) but the run ` +
|
|
766
|
-
'recorded no
|
|
766
|
+
'recorded no DEBUGG_TUNNEL_* marker, so the browser reached the app during the run. Relaying ' +
|
|
767
767
|
"the backend's verdict verbatim rather than blaming the tunnel.");
|
|
768
768
|
}
|
|
769
769
|
}
|
|
@@ -829,23 +829,23 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
829
829
|
// The backend's original verdict is preserved verbatim, never swallowed.
|
|
830
830
|
//
|
|
831
831
|
// Bug 4bui: the stated cause leads with the MARKER, because the marker is the
|
|
832
|
-
// evidence that justified getting here — the run itself recorded
|
|
832
|
+
// evidence that justified getting here — the run itself recorded the tunnel's
|
|
833
833
|
// interstitial. The probe only corroborates (and may legitimately say the
|
|
834
834
|
// tunnel has recovered by now), so it rides in `detail`, never as the cause.
|
|
835
835
|
if (tunnelFault) {
|
|
836
|
-
const { probe,
|
|
836
|
+
const { probe, tunnelErrorCode } = tunnelFault;
|
|
837
837
|
responsePayload.backendVerdict = { outcome: verdict.outcome, reason: verdict.reason };
|
|
838
838
|
responsePayload.outcome = 'error';
|
|
839
839
|
responsePayload.success = false;
|
|
840
840
|
responsePayload.failureCategory = 'infrastructure';
|
|
841
841
|
responsePayload.error = 'TunnelOfflineDuringRun';
|
|
842
842
|
responsePayload.message =
|
|
843
|
-
'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 ' +
|
|
844
844
|
'app, so the check evaluated our error page rather than your UI. This is an infrastructure ' +
|
|
845
845
|
'fault on our side, not a failed check — retry it. The backend\'s original verdict is ' +
|
|
846
846
|
'preserved under `backendVerdict`.';
|
|
847
847
|
responsePayload.reason =
|
|
848
|
-
`The run recorded
|
|
848
|
+
`The run recorded our tunnel's ${tunnelErrorCode} interstitial, so the remote browser reached ` +
|
|
849
849
|
'our tunnel error page instead of your app.' +
|
|
850
850
|
(probe
|
|
851
851
|
? probe.healthy
|
|
@@ -854,7 +854,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
854
854
|
`${probe.status ? ` (HTTP ${probe.status})` : ''}.)`
|
|
855
855
|
: '');
|
|
856
856
|
responsePayload.detail = {
|
|
857
|
-
|
|
857
|
+
tunnelErrorCode,
|
|
858
858
|
probeCode: probe?.code,
|
|
859
859
|
probeStatus: probe?.status,
|
|
860
860
|
probeHealthy: probe?.healthy,
|
|
@@ -880,7 +880,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
880
880
|
}
|
|
881
881
|
// Backend release 2026-04-25: browser_session block on execution detail
|
|
882
882
|
// carries presigned S3 URLs for HAR + console log + recording. Pass through
|
|
883
|
-
// verbatim — sanitizeResponseUrls below only strips
|
|
883
|
+
// verbatim — sanitizeResponseUrls below only strips tunnel hosts so S3 URLs
|
|
884
884
|
// are preserved. Resolves client-feedback items #1 (network) + #7 (console).
|
|
885
885
|
if (finalExecution.browserSession) {
|
|
886
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
|
}
|