@debugg-ai/debugg-ai-mcp 3.9.3 → 4.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/CHANGELOG.md +42 -0
- package/README.md +37 -1
- package/dist/handlers/probePageHandler.js +252 -139
- package/dist/handlers/runTestSuiteHandler.js +70 -51
- package/dist/handlers/testPageChangesHandler.js +32 -1
- package/dist/handlers/triggerCrawlHandler.js +26 -1
- package/dist/services/caddy/caddyProxy.js +611 -0
- package/dist/services/caddy/portLock.js +321 -0
- package/dist/services/ngrok/tunnelManager.js +526 -625
- package/dist/services/ngrok/tunnelRegistry.js +57 -70
- package/dist/services/verdictAdapter.js +2 -0
- package/dist/utils/telemetry.js +16 -0
- package/dist/utils/tunnelContext.js +52 -15
- package/dist/utils/tunnelDisposition.js +9 -17
- package/package.json +3 -1
|
@@ -5,7 +5,8 @@ import { TunnelProvisionError } from '../services/tunnels.js';
|
|
|
5
5
|
import { disposeUnhealthyTunnel } from '../utils/tunnelDisposition.js';
|
|
6
6
|
import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js';
|
|
7
7
|
import { extractLocalhostPort } from '../utils/urlParser.js';
|
|
8
|
-
import { buildContext,
|
|
8
|
+
import { buildContext, sanitizeResponseUrls } from '../utils/tunnelContext.js';
|
|
9
|
+
import { tunnelManager } from '../services/ngrok/tunnelManager.js';
|
|
9
10
|
import { config } from '../config/index.js';
|
|
10
11
|
import { resolveProject, resolveTestSuite } from '../utils/resolveProject.js';
|
|
11
12
|
const logger = new Logger({ module: 'runTestSuiteHandler' });
|
|
@@ -19,6 +20,13 @@ export async function runTestSuiteHandler(input, _context) {
|
|
|
19
20
|
await client.init();
|
|
20
21
|
let acquiredKeyId = null;
|
|
21
22
|
let tunnelId;
|
|
23
|
+
// Used ONLY to scope the defensive sanitizeResponseUrls call below (bead
|
|
24
|
+
// debugg_ai_mcp-6cfv.7) — never fed into acquirePortRoute/PortLock. This
|
|
25
|
+
// handler is fire-and-forget (no poll loop, no bounded window to hold the
|
|
26
|
+
// shared session lock over — see the acquireDedicatedTunnel call below), so
|
|
27
|
+
// it deliberately does NOT go through findExistingTunnel/ensureTunnel/
|
|
28
|
+
// acquirePortRoute at all (§2.3's named, deliberate exception).
|
|
29
|
+
let sanitizeCtx;
|
|
22
30
|
try {
|
|
23
31
|
let suiteUuid = input.suiteUuid;
|
|
24
32
|
if (!suiteUuid) {
|
|
@@ -51,68 +59,79 @@ export async function runTestSuiteHandler(input, _context) {
|
|
|
51
59
|
logger.info(`run_test_suite: dev mode — using localhost URL directly: ${input.targetUrl}`);
|
|
52
60
|
}
|
|
53
61
|
else {
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
// §2.3: run_test_suite is the ONE named, deliberate exception to
|
|
63
|
+
// "one tunnel per session" — it is fire-and-forget (dispatches
|
|
64
|
+
// client.runTestSuite() below and returns; the tests it triggers
|
|
65
|
+
// keep using this tunnel on the BACKEND for possibly many more
|
|
66
|
+
// minutes, entirely outside this process's poll loop, because
|
|
67
|
+
// there IS no poll loop). Sharing the session's Caddy-routed tunnel
|
|
68
|
+
// and its PortLock would give this handler no real protection —
|
|
69
|
+
// the lock would release back to contention seconds after
|
|
70
|
+
// triggering a suite that goes on to use the port for much longer.
|
|
71
|
+
// So it gets its OWN tunnel, dialing ngrok directly, bypassing
|
|
72
|
+
// Caddy/PortLock entirely (never deduped/reused — always fresh).
|
|
73
|
+
let tunnel;
|
|
74
|
+
try {
|
|
75
|
+
tunnel = await client.tunnels.provisionWithRetry();
|
|
59
76
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
// Record the tunnel so the finally block's orphaned-key revoke can't
|
|
92
|
-
// fire: the tunnel we just kept is authenticated with that key, and
|
|
93
|
-
// revoking a live tunnel's credential would kill what we preserved.
|
|
94
|
-
// (On the eviction branch markTunnelDead already revokes it.)
|
|
95
|
-
tunnelId = tunneled.tunnelId;
|
|
96
|
-
return errorResp('TunnelTrafficBlocked', `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`, { code: health.code, ngrokErrorCode: health.ngrokErrorCode, elapsedMs: health.elapsedMs });
|
|
97
|
-
}
|
|
77
|
+
catch (provisionError) {
|
|
78
|
+
const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
|
|
79
|
+
const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
|
|
80
|
+
return errorResp('TunnelProvisionFailed', `Failed to provision tunnel for ${input.targetUrl}. (Detail: ${msg})${diag}`);
|
|
81
|
+
}
|
|
82
|
+
acquiredKeyId = tunnel.keyId;
|
|
83
|
+
let dedicated;
|
|
84
|
+
try {
|
|
85
|
+
dedicated = await tunnelManager.acquireDedicatedTunnel(ctx.originalUrl, tunnel.tunnelKey, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
|
|
86
|
+
}
|
|
87
|
+
catch (tunnelError) {
|
|
88
|
+
const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
|
|
89
|
+
return errorResp('TunnelCreationFailed', `Tunnel creation failed for ${input.targetUrl}. (Detail: ${msg})`);
|
|
90
|
+
}
|
|
91
|
+
// Health probe — catches ERR_NGROK_8012 and bind mismatches before
|
|
92
|
+
// the remote agent wastes steps trying to reach the server.
|
|
93
|
+
if (dedicated.url) {
|
|
94
|
+
const health = await probeTunnelHealth(dedicated.url);
|
|
95
|
+
if (!health.healthy) {
|
|
96
|
+
// Brought in line with the other three handlers, which this one never
|
|
97
|
+
// was (it evicted on EVERY failure and never got bead k34o's shared-
|
|
98
|
+
// registry eviction). Evict only on a code proving the endpoint is
|
|
99
|
+
// gone: a transient edge flake must not cost two billed hours.
|
|
100
|
+
// See utils/tunnelDisposition.ts.
|
|
101
|
+
disposeUnhealthyTunnel({ health, tunnelId: dedicated.tunnelId, originalUrl: ctx.originalUrl });
|
|
102
|
+
// Record the tunnel so the finally block's orphaned-key revoke can't
|
|
103
|
+
// fire: the tunnel we just kept is authenticated with that key, and
|
|
104
|
+
// revoking a live tunnel's credential would kill what we preserved.
|
|
105
|
+
// (On the eviction branch markTunnelDead already revokes it.)
|
|
106
|
+
tunnelId = dedicated.tunnelId;
|
|
107
|
+
return errorResp('TunnelTrafficBlocked', `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`, { code: health.code, ngrokErrorCode: health.ngrokErrorCode, elapsedMs: health.elapsedMs });
|
|
98
108
|
}
|
|
99
|
-
effectiveTargetUrl = tunneled.targetUrl ?? input.targetUrl;
|
|
100
|
-
tunnelId = tunneled.tunnelId;
|
|
101
109
|
}
|
|
110
|
+
effectiveTargetUrl = dedicated.url;
|
|
111
|
+
tunnelId = dedicated.tunnelId;
|
|
112
|
+
sanitizeCtx = { ...ctx, tunnelId: dedicated.tunnelId, targetUrl: dedicated.url };
|
|
102
113
|
logger.info(`run_test_suite: localhost detected, tunneled ${input.targetUrl} → ${effectiveTargetUrl}`);
|
|
103
114
|
}
|
|
104
115
|
}
|
|
105
116
|
}
|
|
106
117
|
const result = await client.runTestSuite(suiteUuid, { targetUrl: effectiveTargetUrl });
|
|
107
118
|
logger.toolComplete('run_test_suite', Date.now() - start);
|
|
119
|
+
const responsePayload = {
|
|
120
|
+
...result,
|
|
121
|
+
...(tunnelId ? { tunnelActive: true, originalUrl: input.targetUrl } : {}),
|
|
122
|
+
note: 'Tests are running asynchronously. Use get_test_suite_results to check progress.',
|
|
123
|
+
};
|
|
124
|
+
// Bead debugg_ai_mcp-6cfv.7: this handler previously had NO sanitize call
|
|
125
|
+
// at all — safe only by accident of `result`'s narrow return type never
|
|
126
|
+
// having carried a tunnel hostname in practice. Add the same defensive
|
|
127
|
+
// pass every other handler already runs, so a future backend field that
|
|
128
|
+
// echoes back the (dedicated, ngrok-direct) tunnel URL can never leak it
|
|
129
|
+
// to a caller who only knows their own localhost address.
|
|
130
|
+
const sanitizedPayload = sanitizeCtx ? sanitizeResponseUrls(responsePayload, sanitizeCtx) : responsePayload;
|
|
108
131
|
return {
|
|
109
132
|
content: [{
|
|
110
133
|
type: 'text',
|
|
111
|
-
text: JSON.stringify(
|
|
112
|
-
...result,
|
|
113
|
-
...(tunnelId ? { tunnelActive: true, originalUrl: input.targetUrl } : {}),
|
|
114
|
-
note: 'Tests are running asynchronously. Use get_test_suite_results to check progress.',
|
|
115
|
-
}, null, 2),
|
|
134
|
+
text: JSON.stringify(sanitizedPayload, null, 2),
|
|
116
135
|
}],
|
|
117
136
|
};
|
|
118
137
|
}
|
|
@@ -11,7 +11,8 @@ import { DebuggAIServerClient } from '../services/index.js';
|
|
|
11
11
|
import { getEvalTemplateSlug } from '../services/workflows.js';
|
|
12
12
|
import { adaptVerdict, isEnvironmentDefault } from '../services/verdictAdapter.js';
|
|
13
13
|
import { TunnelProvisionError } from '../services/tunnels.js';
|
|
14
|
-
import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
|
|
14
|
+
import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, acquirePortRoute, releasePortRoute, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
15
16
|
import { detectRepoName } from '../utils/gitContext.js';
|
|
16
17
|
import { disposeUnhealthyTunnel } from '../utils/tunnelDisposition.js';
|
|
17
18
|
import { probeLocalPort, probeTunnelHealth, extractNgrokErrorCode } from '../utils/localReachability.js';
|
|
@@ -175,6 +176,9 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
175
176
|
logger.warn(`Best-effort cancel of abandoned execution ${uuid} threw synchronously: ${err}`);
|
|
176
177
|
}
|
|
177
178
|
};
|
|
179
|
+
// Unique per-call id for the shared port-route lock's holder bookkeeping
|
|
180
|
+
// (§2.4) — reused for abort wiring purposes elsewhere in this handler.
|
|
181
|
+
const callId = randomUUID();
|
|
178
182
|
const abortController = new AbortController();
|
|
179
183
|
const onAbort = () => {
|
|
180
184
|
clientAborted = true;
|
|
@@ -257,6 +261,24 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
257
261
|
}
|
|
258
262
|
logger.info(`Tunnel ready: ${ctx.targetUrl} (id: ${ctx.tunnelId})`);
|
|
259
263
|
}
|
|
264
|
+
// §2.4: acquire this session's shared Caddy route for our port BEFORE
|
|
265
|
+
// probing/dispatching — probing before the repoint is confirmed would
|
|
266
|
+
// probe whatever port happened to be active a moment ago. Blocks here
|
|
267
|
+
// (not just during the repoint) until any different-port holder in
|
|
268
|
+
// this same session releases.
|
|
269
|
+
ctx = await acquirePortRoute(ctx, {
|
|
270
|
+
callId,
|
|
271
|
+
signal: abortController.signal,
|
|
272
|
+
onWaitProgress: progressCallback
|
|
273
|
+
? async (info) => {
|
|
274
|
+
await progressCallback({
|
|
275
|
+
progress: 1,
|
|
276
|
+
total: TOTAL_STEPS,
|
|
277
|
+
message: `Waiting for shared tunnel — port ${info.blockingPort} is in use (waited ${Math.round(info.waitedMs / 1000)}s)...`,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
: undefined,
|
|
281
|
+
});
|
|
260
282
|
// Bead 1om: verify traffic actually flows through the tunnel. The
|
|
261
283
|
// tunnel can be established (ngrok.connect returns OK) yet refuse
|
|
262
284
|
// to forward traffic — e.g., IPv4/IPv6 bind mismatch, or the dev
|
|
@@ -721,6 +743,11 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
721
743
|
// bug. Relay the logins verbatim, and when the caller named an account but
|
|
722
744
|
// an environment default was used anyway, say so explicitly rather than
|
|
723
745
|
// leaving the caller to infer it from a failed check.
|
|
746
|
+
// For "navigate and describe what you see", the answer IS the deliverable —
|
|
747
|
+
// and it reached callers only incidentally, buried in actionTrace[0].intent,
|
|
748
|
+
// which the verify-gate rewrites when it disagrees with the page. Surface it.
|
|
749
|
+
if (verdict.report)
|
|
750
|
+
responsePayload.report = verdict.report;
|
|
724
751
|
if (verdict.logins)
|
|
725
752
|
responsePayload.logins = verdict.logins;
|
|
726
753
|
if (verdict.loginError)
|
|
@@ -908,6 +935,10 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
908
935
|
finally {
|
|
909
936
|
if (requestSignal)
|
|
910
937
|
requestSignal.removeEventListener('abort', onAbort);
|
|
938
|
+
// §2.4: release this call's claim on the shared port route (no-op if we
|
|
939
|
+
// never acquired one — public URL, dev mode, or an early-return before
|
|
940
|
+
// acquisition). Covers every early-return path above for free.
|
|
941
|
+
releasePortRoute(ctx);
|
|
911
942
|
// Tunnel is intentionally NOT torn down here — tunnelManager reuses it on
|
|
912
943
|
// subsequent calls to the same port and auto-shutoffs after 55 min idle.
|
|
913
944
|
// Process-exit cleanup happens via stopAllTunnels() in the SIGINT/SIGTERM
|
|
@@ -18,7 +18,8 @@ import { TunnelProvisionError } from '../services/tunnels.js';
|
|
|
18
18
|
import { disposeUnhealthyTunnel } from '../utils/tunnelDisposition.js';
|
|
19
19
|
import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js';
|
|
20
20
|
import { extractLocalhostPort } from '../utils/urlParser.js';
|
|
21
|
-
import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
|
|
21
|
+
import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, acquirePortRoute, releasePortRoute, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
|
|
22
|
+
import { randomUUID } from 'node:crypto';
|
|
22
23
|
import { getCachedTemplateUuid, invalidateTemplateCache } from '../utils/handlerCaches.js';
|
|
23
24
|
import { detectLocalGitRef } from '../utils/gitContext.js';
|
|
24
25
|
import { getCrawlTemplateSlug } from '../services/workflows.js';
|
|
@@ -67,6 +68,8 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
67
68
|
// we get: stdin is not the transport, so the old stdin 'close' listener never
|
|
68
69
|
// fired and a dropped client kept polling for up to ~10 min. Wiring to
|
|
69
70
|
// context.signal cancels the poll immediately (parity with 56kd.5).
|
|
71
|
+
// Unique per-call id for the shared port-route lock's holder bookkeeping (§2.4).
|
|
72
|
+
const callId = randomUUID();
|
|
70
73
|
const abortController = new AbortController();
|
|
71
74
|
const onAbort = () => {
|
|
72
75
|
abortController.abort();
|
|
@@ -123,6 +126,25 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
123
126
|
keyId = tunnel.keyId;
|
|
124
127
|
ctx = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
|
|
125
128
|
}
|
|
129
|
+
// §2.4: acquire this session's shared Caddy route for our port BEFORE
|
|
130
|
+
// probing/dispatching — trigger_crawl polls (pollExecution below) for
|
|
131
|
+
// the full duration real backend traffic can occur, exactly like
|
|
132
|
+
// check_app_in_browser/probe_page, so it holds this lock for the
|
|
133
|
+
// whole call, not just the repoint (verified: it is NOT a
|
|
134
|
+
// fire-and-forget exception — only run_test_suite is, §2.3).
|
|
135
|
+
ctx = await acquirePortRoute(ctx, {
|
|
136
|
+
callId,
|
|
137
|
+
signal: abortController.signal,
|
|
138
|
+
onWaitProgress: progressCallback
|
|
139
|
+
? async (info) => {
|
|
140
|
+
await progressCallback({
|
|
141
|
+
progress: 1,
|
|
142
|
+
total: 4,
|
|
143
|
+
message: `Waiting for shared tunnel — port ${info.blockingPort} is in use (waited ${Math.round(info.waitedMs / 1000)}s)...`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
: undefined,
|
|
147
|
+
});
|
|
126
148
|
// Bead 1om: post-tunnel health check — verify traffic actually flows.
|
|
127
149
|
if (ctx.targetUrl) {
|
|
128
150
|
const health = await probeTunnelHealth(ctx.targetUrl);
|
|
@@ -336,6 +358,9 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
336
358
|
finally {
|
|
337
359
|
if (requestSignal)
|
|
338
360
|
requestSignal.removeEventListener('abort', onAbort);
|
|
361
|
+
// §2.4: release this call's claim on the shared port route (no-op if we
|
|
362
|
+
// never acquired one).
|
|
363
|
+
releasePortRoute(ctx);
|
|
339
364
|
// Tunnel intentionally NOT torn down (reuse path per bead vwd).
|
|
340
365
|
// If tunnel creation failed after key provision, revoke the orphaned key.
|
|
341
366
|
if (!ctx.tunnelId && keyId) {
|