@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.
@@ -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, findExistingTunnel, ensureTunnel } from '../utils/tunnelContext.js';
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
- // Reuse an existing tunnel for this port if one is already active.
55
- const reused = findExistingTunnel(ctx);
56
- if (reused) {
57
- effectiveTargetUrl = reused.targetUrl ?? input.targetUrl;
58
- tunnelId = reused.tunnelId;
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
- else {
61
- // Provision a new tunnel.
62
- let tunnel;
63
- try {
64
- tunnel = await client.tunnels.provisionWithRetry();
65
- }
66
- catch (provisionError) {
67
- const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
68
- const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
69
- return errorResp('TunnelProvisionFailed', `Failed to provision tunnel for ${input.targetUrl}. (Detail: ${msg})${diag}`);
70
- }
71
- acquiredKeyId = tunnel.keyId;
72
- let tunneled;
73
- try {
74
- tunneled = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
75
- }
76
- catch (tunnelError) {
77
- const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
78
- return errorResp('TunnelCreationFailed', `Tunnel creation failed for ${input.targetUrl}. (Detail: ${msg})`);
79
- }
80
- // Health probe catches ERR_NGROK_8012 and bind mismatches before
81
- // the remote agent wastes steps trying to reach the server.
82
- if (tunneled.targetUrl) {
83
- const health = await probeTunnelHealth(tunneled.targetUrl);
84
- if (!health.healthy) {
85
- // Brought in line with the other three handlers, which this one never
86
- // was (it evicted on EVERY failure and never got bead k34o's shared-
87
- // registry eviction). Evict only on a code proving the endpoint is
88
- // gone: a transient edge flake must not cost two billed hours.
89
- // See utils/tunnelDisposition.ts.
90
- disposeUnhealthyTunnel({ health, tunnelId: tunneled.tunnelId, originalUrl: ctx.originalUrl });
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) {