@debugg-ai/debugg-ai-mcp 3.10.0 → 4.1.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.
@@ -21,12 +21,41 @@ import { TunnelProvisionError } from '../services/tunnels.js';
21
21
  import { disposeUnhealthyTunnel } from '../utils/tunnelDisposition.js';
22
22
  import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js';
23
23
  import { extractLocalhostPort } from '../utils/urlParser.js';
24
- import { buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
24
+ import { buildContext, findExistingTunnel, ensureTunnel, acquirePortRoute, releasePortRoute, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
25
+ import { randomUUID } from 'node:crypto';
25
26
  import { getCachedTemplateUuid, invalidateTemplateCache } from '../utils/handlerCaches.js';
26
27
  import { getPageProbeTemplateSlug } from '../services/workflows.js';
27
28
  import { reaggregateByOriginPath, mapConsoleSlice } from '../utils/harSummarizer.js';
28
29
  import { fetchImageAsBase64, imageContentBlock } from '../utils/imageUtils.js';
29
30
  const logger = new Logger({ module: 'probePageHandler' });
31
+ function groupTargetsByRoute(targetContexts) {
32
+ const groups = [];
33
+ const byKey = new Map();
34
+ for (let i = 0; i < targetContexts.length; i++) {
35
+ const tc = targetContexts[i];
36
+ let key;
37
+ let needsRoute = false;
38
+ if (tc.isLocalhost && tc.tunnelId) {
39
+ const port = extractLocalhostPort(tc.originalUrl);
40
+ const isHttpsLocal = tc.originalUrl.startsWith('https:');
41
+ key = `local:${port}:${isHttpsLocal}`;
42
+ needsRoute = true;
43
+ }
44
+ else {
45
+ // Public URLs (and dev-mode localhost, which never gets a tunnelId)
46
+ // share ONE group — no route to serialize, no reason to split them.
47
+ key = 'public';
48
+ }
49
+ let group = byKey.get(key);
50
+ if (!group) {
51
+ group = { key, indices: [], needsRoute };
52
+ byKey.set(key, group);
53
+ groups.push(group);
54
+ }
55
+ group.indices.push(i);
56
+ }
57
+ return groups;
58
+ }
30
59
  export async function probePageHandler(input, context, rawProgressCallback) {
31
60
  const startTime = Date.now();
32
61
  logger.toolStart('probe_page', input);
@@ -68,6 +97,10 @@ export async function probePageHandler(input, context, rawProgressCallback) {
68
97
  else
69
98
  requestSignal.addEventListener('abort', onAbort, { once: true });
70
99
  }
100
+ // Base id for the shared port-route lock's holder bookkeeping (§2.4) — one
101
+ // per target GROUP, suffixed below, since a multi-port batch acquires the
102
+ // route once per group, sequentially.
103
+ const callId = randomUUID();
71
104
  // Per-target tunnel contexts. Index aligns with input.targets[].
72
105
  const targetContexts = [];
73
106
  // Tunnel keys we provisioned this call (for cleanup if creation fails after key acquired).
@@ -133,30 +166,15 @@ export async function probePageHandler(input, context, rawProgressCallback) {
133
166
  const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
134
167
  throw new Error(`Tunnel creation failed for ${ctx.originalUrl}. (Detail: ${msg})`);
135
168
  }
136
- // Tunnel health probe: catch the IPv4/IPv6 bind / dead-server case
137
- // before committing to a full backend execution.
138
- if (tunneled.targetUrl) {
139
- const health = await probeTunnelHealth(tunneled.targetUrl);
140
- if (!health.healthy) {
141
- const payload = {
142
- error: 'TunnelTrafficBlocked',
143
- message: `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`,
144
- detail: {
145
- code: health.code,
146
- status: health.status,
147
- ngrokErrorCode: health.ngrokErrorCode,
148
- elapsedMs: health.elapsedMs,
149
- },
150
- };
151
- // Evict ONLY on a code proving the endpoint is gone; every other
152
- // failure keeps the tunnel we are already paying for, since a
153
- // teardown+re-provision costs two billed hours and this probe
154
- // cannot tell a dead endpoint from a transient edge flake. See
155
- // utils/tunnelDisposition.ts for the allowlist and the evidence.
156
- disposeUnhealthyTunnel({ health, tunnelId: tunneled.tunnelId, originalUrl: tunneled.originalUrl });
157
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
158
- }
159
- }
169
+ // NOTE: the tunnel health probe used to run right here, immediately
170
+ // after ensureTunnel. Under the session-tunnel + Caddy model that is
171
+ // WRONG — ensureTunnel only confirms the tunnel reaches Caddy, not
172
+ // that Caddy is pointed at THIS target's port yet (that's
173
+ // acquirePortRoute's job, per-group, below). Probing here would
174
+ // probe whatever port Caddy happened to be pointed at a moment ago
175
+ // (often the placeholder, on a session's first call), not this
176
+ // target's port see utils/tunnelContext.ts's acquirePortRoute
177
+ // doc comment. Moved to right after acquirePortRoute succeeds.
160
178
  targetContexts.push(tunneled);
161
179
  }
162
180
  }
@@ -181,140 +199,235 @@ export async function probePageHandler(input, context, rawProgressCallback) {
181
199
  `Ensure the backend has that template seeded and accessible ` +
182
200
  `(GET /api/v1/workflows/?slug=${templateSlug}).`);
183
201
  }
184
- // ── Build contextData (camelCase; axiosTransport snake_cases on the wire) ──
185
- // Backend's browser.setup node (shared with App Evaluation + Raw Crawl
186
- // templates) requires `target_url` (singular). The Page Probe template
187
- // currently uses that node as-is the per-target loop primitive is
188
- // pending. Send BOTH:
189
- // - targetUrl: first target's tunneled URL (satisfies browser.setup
190
- // today; will keep working when the loop wraps it later)
191
- // - targets[]: the full per-URL config for when the loop primitive
192
- // ships and iterates over them
193
- const firstTargetUrl = targetContexts[0]?.targetUrl ?? input.targets[0].url;
194
- const contextData = {
195
- targetUrl: firstTargetUrl,
196
- targets: input.targets.map((t, i) => ({
197
- url: targetContexts[i].targetUrl ?? t.url,
198
- // Send null (not undefined) for optional fields so the field exists
199
- // in the target object even when the caller didn't pass one. Backend
200
- // placeholder resolver was fixed in commit 154e1e69 to type-preserve
201
- // null in single-placeholder substitutions, so null flows through.
202
- waitForSelector: t.waitForSelector ?? null,
203
- waitForLoadState: t.waitForLoadState,
204
- timeoutMs: t.timeoutMs,
205
- })),
206
- // Backend's browser.capture template binds {{include_dom}} and
207
- // {{include_screenshot}} from contextData (verified 2026-04-29).
208
- // The MCP-facing schema keeps `includeHtml` / `captureScreenshots`
209
- // for caller ergonomics; we just map them to what the template wants.
210
- includeDom: input.includeHtml,
211
- includeScreenshot: input.captureScreenshots,
212
- // Keep the original keys too for any downstream node that reads them
213
- // (cheap to send, future-proof against template field-name churn).
214
- includeHtml: input.includeHtml,
215
- captureScreenshots: input.captureScreenshots,
216
- };
217
- // ── Execute ────────────────────────────────────────────────────────────
202
+ // ── Group targets by shared Caddy route (§4 multi-port batch decision) ──
203
+ // Single-group batches (the common case one port, or all-public) behave
204
+ // EXACTLY as before: one repoint (if localhost), one backend execution.
205
+ // Multi-group batches decompose into one sequential
206
+ // acquirePortRoute executeWorkflow(subset) → poll → releasePortRoute
207
+ // cycle per group slower (N backend round-trips) but correct, rather
208
+ // than hard-rejecting a batch shape that worked when it was single-port.
209
+ const groups = groupTargetsByRoute(targetContexts);
210
+ // ── Execute (queuing progress step, once — shared across all groups) ───
218
211
  if (progressCallback) {
219
212
  await progressCallback({ progress: ++progressStep, total: TOTAL_STEPS, message: 'Queuing workflow execution...' });
220
213
  }
221
- const executeResponse = await client.workflows.executeWorkflow(templateUuid, contextData);
222
- const executionUuid = executeResponse.executionUuid;
223
- logger.info(`Probe execution queued: ${executionUuid}`);
224
- // ── Poll ───────────────────────────────────────────────────────────────
225
- let lastCompleted = -1;
226
- const finalExecution = await client.workflows.pollExecution(executionUuid, async (exec) => {
227
- // Keep all active tunnels alive during polling.
228
- for (const tc of targetContexts) {
229
- if (tc.tunnelId)
230
- touchTunnelById(tc.tunnelId);
231
- }
232
- if (!progressCallback)
233
- return;
234
- const completedNodes = (exec.nodeExecutions ?? []).filter(n => n.nodeType === 'browser.capture' && n.status === 'success').length;
235
- if (completedNodes !== lastCompleted) {
236
- lastCompleted = completedNodes;
237
- await progressCallback({
238
- progress: Math.min(progressStep + completedNodes, TOTAL_STEPS - 1),
239
- total: TOTAL_STEPS,
240
- message: `Probed ${completedNodes}/${input.targets.length} target${input.targets.length === 1 ? '' : 's'}...`,
214
+ const results = new Array(input.targets.length);
215
+ const captureDataByIndex = new Array(input.targets.length);
216
+ const executionIds = [];
217
+ const browserSessions = [];
218
+ let lastExecutionDurationMs;
219
+ let completedOffset = 0;
220
+ for (const group of groups) {
221
+ // §2.4: acquire this session's shared Caddy route for the group's port
222
+ // BEFORE dispatching — held for the group's whole execute+poll cycle,
223
+ // not just the repoint. No-op (undefined) for the 'public' group.
224
+ let groupCtx;
225
+ if (group.needsRoute) {
226
+ const representative = targetContexts[group.indices[0]];
227
+ groupCtx = await acquirePortRoute(representative, {
228
+ callId: `${callId}:${group.key}`,
229
+ signal: abortController.signal,
230
+ onWaitProgress: progressCallback
231
+ ? async (info) => {
232
+ await progressCallback({
233
+ progress: Math.min(progressStep + completedOffset, TOTAL_STEPS - 1),
234
+ total: TOTAL_STEPS,
235
+ message: `Waiting for shared tunnel — port ${info.blockingPort} is in use (waited ${Math.round(info.waitedMs / 1000)}s)...`,
236
+ });
237
+ }
238
+ : undefined,
241
239
  });
242
240
  }
243
- }, abortController.signal);
244
- // ── Format response ────────────────────────────────────────────────────
245
- const duration = Date.now() - startTime;
246
- const captureNodes = (finalExecution.nodeExecutions ?? [])
247
- .filter(n => n.nodeType === 'browser.capture')
248
- .sort((a, b) => a.executionOrder - b.executionOrder);
249
- const results = [];
250
- for (let i = 0; i < input.targets.length; i++) {
251
- const target = input.targets[i];
252
- const node = captureNodes[i];
253
- const data = node?.outputData ?? {};
254
- // Backend (post-154e1e69) emits browser.capture output_data with:
255
- // captured_url, status_code, title, load_time_ms,
256
- // console_slice (already per-capture, in {text, level, location, timestamp} shape),
257
- // network_summary (already pre-aggregated by FULL URL,
258
- // in {url, count, methods[], statuses{}, resource_types[]} shape),
259
- // surfer_page_uuid (reference to SurferPage row for screenshot/title/visible_text),
260
- // error
261
- // axiosTransport snake→camel'd at the wire, so JS-side these are
262
- // capturedUrl / consoleSlice / networkSummary / surferPageUuid / etc.
263
- // Re-aggregate networkSummary by origin+pathname so refetch loops
264
- // collapse (preserves the original client-feedback contract).
265
- const result = {
266
- url: target.url, // ORIGINAL caller URL not the tunneled rewrite
267
- finalUrl: typeof data.capturedUrl === 'string' ? data.capturedUrl
268
- : typeof data.finalUrl === 'string' ? data.finalUrl
269
- : typeof data.url === 'string' ? data.url
270
- : target.url,
271
- statusCode: typeof data.statusCode === 'number' ? data.statusCode : 0,
272
- title: typeof data.title === 'string' ? data.title : null,
273
- loadTimeMs: typeof data.loadTimeMs === 'number' ? data.loadTimeMs : 0,
274
- consoleErrors: mapConsoleSlice(Array.isArray(data.consoleSlice) ? data.consoleSlice : []),
275
- networkSummary: reaggregateByOriginPath(Array.isArray(data.networkSummary) ? data.networkSummary : []),
276
- };
277
- if (input.includeHtml && typeof data.html === 'string') {
278
- result.html = data.html;
279
- }
280
- if (typeof data.error === 'string' && data.error) {
281
- result.error = data.error;
241
+ try {
242
+ // Tunnel health probe: catch the IPv4/IPv6 bind / dead-server case
243
+ // before committing to a full backend execution. Must run AFTER
244
+ // acquirePortRoute (above) confirms Caddy is actually pointed at this
245
+ // group's port probing any earlier would probe whatever port Caddy
246
+ // happened to be pointed at a moment ago, not this one (see
247
+ // utils/tunnelContext.ts's acquirePortRoute doc comment).
248
+ if (groupCtx?.targetUrl) {
249
+ const health = await probeTunnelHealth(groupCtx.targetUrl);
250
+ if (!health.healthy) {
251
+ const payload = {
252
+ error: 'TunnelTrafficBlocked',
253
+ message: `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`,
254
+ detail: {
255
+ code: health.code,
256
+ status: health.status,
257
+ ngrokErrorCode: health.ngrokErrorCode,
258
+ elapsedMs: health.elapsedMs,
259
+ },
260
+ };
261
+ // Evict ONLY on a code proving the endpoint is gone; every other
262
+ // failure keeps the tunnel we are already paying for, since a
263
+ // teardown+re-provision costs two billed hours and this probe
264
+ // cannot tell a dead endpoint from a transient edge flake. See
265
+ // utils/tunnelDisposition.ts for the allowlist and the evidence.
266
+ disposeUnhealthyTunnel({ health, tunnelId: groupCtx.tunnelId, originalUrl: groupCtx.originalUrl });
267
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
268
+ }
269
+ }
270
+ // ── Build contextData for THIS group's subset (camelCase; axiosTransport
271
+ // snake_cases on the wire) ──────────────────────────────────────────
272
+ // Backend's browser.setup node (shared with App Evaluation + Raw Crawl
273
+ // templates) requires `target_url` (singular). Send BOTH:
274
+ // - targetUrl: this group's first target's tunneled URL (satisfies
275
+ // browser.setup today; will keep working when the loop wraps it later)
276
+ // - targets[]: the full per-URL config for when the loop primitive
277
+ // ships and iterates over them
278
+ const groupTargets = group.indices.map(i => input.targets[i]);
279
+ const firstTargetUrl = targetContexts[group.indices[0]]?.targetUrl ?? groupTargets[0].url;
280
+ const contextData = {
281
+ targetUrl: firstTargetUrl,
282
+ targets: group.indices.map(i => ({
283
+ url: targetContexts[i].targetUrl ?? input.targets[i].url,
284
+ // Send null (not undefined) for optional fields so the field exists
285
+ // in the target object even when the caller didn't pass one. Backend
286
+ // placeholder resolver was fixed in commit 154e1e69 to type-preserve
287
+ // null in single-placeholder substitutions, so null flows through.
288
+ waitForSelector: input.targets[i].waitForSelector ?? null,
289
+ waitForLoadState: input.targets[i].waitForLoadState,
290
+ timeoutMs: input.targets[i].timeoutMs,
291
+ })),
292
+ // Backend's browser.capture template binds {{include_dom}} and
293
+ // {{include_screenshot}} from contextData (verified 2026-04-29).
294
+ // The MCP-facing schema keeps `includeHtml` / `captureScreenshots`
295
+ // for caller ergonomics; we just map them to what the template wants.
296
+ includeDom: input.includeHtml,
297
+ includeScreenshot: input.captureScreenshots,
298
+ // Keep the original keys too for any downstream node that reads them
299
+ // (cheap to send, future-proof against template field-name churn).
300
+ includeHtml: input.includeHtml,
301
+ captureScreenshots: input.captureScreenshots,
302
+ };
303
+ const executeResponse = await client.workflows.executeWorkflow(templateUuid, contextData);
304
+ const executionUuid = executeResponse.executionUuid;
305
+ executionIds.push(executionUuid);
306
+ logger.info(`Probe execution queued: ${executionUuid} (group ${group.key}, ${group.indices.length} target(s))`);
307
+ // ── Poll ───────────────────────────────────────────────────────────
308
+ let lastCompletedInGroup = -1;
309
+ const finalExecution = await client.workflows.pollExecution(executionUuid, async (exec) => {
310
+ // Keep this group's active tunnels alive during polling.
311
+ for (const i of group.indices) {
312
+ const tunnelId = targetContexts[i].tunnelId;
313
+ if (tunnelId)
314
+ touchTunnelById(tunnelId);
315
+ }
316
+ if (!progressCallback)
317
+ return;
318
+ const completedNodes = (exec.nodeExecutions ?? []).filter(n => n.nodeType === 'browser.capture' && n.status === 'success').length;
319
+ if (completedNodes !== lastCompletedInGroup) {
320
+ lastCompletedInGroup = completedNodes;
321
+ const totalCompleted = completedOffset + completedNodes;
322
+ await progressCallback({
323
+ progress: Math.min(progressStep + totalCompleted, TOTAL_STEPS - 1),
324
+ total: TOTAL_STEPS,
325
+ message: `Probed ${totalCompleted}/${input.targets.length} target${input.targets.length === 1 ? '' : 's'}...`,
326
+ });
327
+ }
328
+ }, abortController.signal);
329
+ lastExecutionDurationMs = finalExecution.durationMs ?? undefined;
330
+ if (finalExecution.browserSession)
331
+ browserSessions.push(finalExecution.browserSession);
332
+ // ── Format this group's results into the shared, ORIGINAL-order arrays ──
333
+ const captureNodes = (finalExecution.nodeExecutions ?? [])
334
+ .filter(n => n.nodeType === 'browser.capture')
335
+ .sort((a, b) => a.executionOrder - b.executionOrder);
336
+ for (let gi = 0; gi < group.indices.length; gi++) {
337
+ const originalIndex = group.indices[gi];
338
+ const target = input.targets[originalIndex];
339
+ const node = captureNodes[gi];
340
+ const data = node?.outputData ?? {};
341
+ captureDataByIndex[originalIndex] = data;
342
+ // Backend (post-154e1e69) emits browser.capture output_data with:
343
+ // captured_url, status_code, title, load_time_ms,
344
+ // console_slice (already per-capture, in {text, level, location, timestamp} shape),
345
+ // network_summary (already pre-aggregated by FULL URL,
346
+ // in {url, count, methods[], statuses{}, resource_types[]} shape),
347
+ // surfer_page_uuid (reference to SurferPage row for screenshot/title/visible_text),
348
+ // error
349
+ // axiosTransport snake→camel'd at the wire, so JS-side these are
350
+ // capturedUrl / consoleSlice / networkSummary / surferPageUuid / etc.
351
+ // Re-aggregate networkSummary by origin+pathname so refetch loops
352
+ // collapse (preserves the original client-feedback contract).
353
+ const result = {
354
+ url: target.url, // ORIGINAL caller URL — not the tunneled rewrite
355
+ finalUrl: typeof data.capturedUrl === 'string' ? data.capturedUrl
356
+ : typeof data.finalUrl === 'string' ? data.finalUrl
357
+ : typeof data.url === 'string' ? data.url
358
+ : target.url,
359
+ statusCode: typeof data.statusCode === 'number' ? data.statusCode : 0,
360
+ title: typeof data.title === 'string' ? data.title : null,
361
+ loadTimeMs: typeof data.loadTimeMs === 'number' ? data.loadTimeMs : 0,
362
+ consoleErrors: mapConsoleSlice(Array.isArray(data.consoleSlice) ? data.consoleSlice : []),
363
+ networkSummary: reaggregateByOriginPath(Array.isArray(data.networkSummary) ? data.networkSummary : []),
364
+ };
365
+ if (input.includeHtml && typeof data.html === 'string') {
366
+ result.html = data.html;
367
+ }
368
+ if (typeof data.error === 'string' && data.error) {
369
+ result.error = data.error;
370
+ }
371
+ if (typeof data.surferPageUuid === 'string' && data.surferPageUuid) {
372
+ result.surferPageUuid = data.surferPageUuid;
373
+ }
374
+ // Bead debugg_ai_mcp-6cfv.6 fix: sanitize each result against ITS
375
+ // OWN target's tunnel context, never the whole accumulated payload.
376
+ // Under the old per-port-tunnel model every target had a distinct
377
+ // hostname, so rewriting the whole payload on each pass was safe by
378
+ // accident. Under the new session-tunnel model every localhost
379
+ // target in a batch can share ONE hostname — sanitizing the whole
380
+ // payload with target A's context would also rewrite target B's own
381
+ // (correct) occurrences, a cross-target data leak. Scoping the
382
+ // sanitize call to `result`'s own subtree, keyed by `targetContexts[originalIndex]`,
383
+ // makes that leak structurally impossible regardless of how many
384
+ // targets share a hostname.
385
+ const tc = targetContexts[originalIndex];
386
+ results[originalIndex] = tc.isLocalhost ? sanitizeResponseUrls(result, tc) : result;
387
+ }
388
+ completedOffset += group.indices.length;
282
389
  }
283
- if (typeof data.surferPageUuid === 'string' && data.surferPageUuid) {
284
- result.surferPageUuid = data.surferPageUuid;
390
+ finally {
391
+ if (groupCtx)
392
+ releasePortRoute(groupCtx);
285
393
  }
286
- results.push(result);
287
394
  }
395
+ // ── Format response ────────────────────────────────────────────────────
396
+ const duration = Date.now() - startTime;
288
397
  const responsePayload = {
289
- executionId: executionUuid,
290
- durationMs: typeof finalExecution.durationMs === 'number' ? finalExecution.durationMs : duration,
398
+ executionId: executionIds[0],
399
+ // Single-group batches (every existing caller) keep the exact prior
400
+ // semantics: the backend's own reported durationMs when present, wall
401
+ // clock otherwise. Multi-group batches use wall clock — summing/picking
402
+ // among several backend-reported per-execution durations would be
403
+ // misleading, since the executions ran sequentially, not concurrently.
404
+ durationMs: groups.length === 1 && typeof lastExecutionDurationMs === 'number'
405
+ ? lastExecutionDurationMs
406
+ : duration,
291
407
  results,
292
408
  };
293
- if (finalExecution.browserSession) {
294
- responsePayload.browserSession = finalExecution.browserSession;
409
+ // Multi-group batches ran more than one backend execution — surface all
410
+ // of their ids/sessions additively, without changing the single-group
411
+ // (single-execution) response shape any existing caller already parses.
412
+ if (executionIds.length > 1)
413
+ responsePayload.executionIds = executionIds;
414
+ if (browserSessions.length === 1) {
415
+ responsePayload.browserSession = browserSessions[0];
295
416
  }
296
- // Sanitize ngrok URLs from the entire payload — agent-authored strings in
297
- // node outputData (titles, HTML, console messages from the page itself)
298
- // can occasionally contain the tunnel URL; rewrite to the original
299
- // localhost origin per tunnel context. For multi-localhost batches we
300
- // run sanitize once per localhost target since each may have its own
301
- // tunnel↔origin mapping.
302
- let sanitizedPayload = responsePayload;
303
- for (const tc of targetContexts) {
304
- if (tc.isLocalhost) {
305
- sanitizedPayload = sanitizeResponseUrls(sanitizedPayload, tc);
306
- }
417
+ else if (browserSessions.length > 1) {
418
+ responsePayload.browserSessions = browserSessions;
307
419
  }
308
420
  logger.toolComplete('probe_page', duration);
309
421
  const responseContent = [
310
- { type: 'text', text: JSON.stringify(sanitizedPayload, null, 2) },
422
+ { type: 'text', text: JSON.stringify(responsePayload, null, 2) },
311
423
  ];
312
424
  // Embed screenshots when captureScreenshots is true. The backend may return
313
425
  // screenshotB64 or a URL-keyed field on browser.capture outputData.
314
426
  if (input.captureScreenshots) {
315
427
  const SCREENSHOT_URL_KEYS = ['screenshotB64', 'screenshot', 'screenshotUrl', 'screenshotUri', 'finalScreenshot'];
316
- for (const node of captureNodes) {
317
- const data = node?.outputData ?? {};
428
+ for (const data of captureDataByIndex) {
429
+ if (!data)
430
+ continue;
318
431
  if (typeof data.screenshotB64 === 'string' && data.screenshotB64) {
319
432
  responseContent.push(imageContentBlock(data.screenshotB64, 'image/png'));
320
433
  }
@@ -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
  }