@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.
- package/CHANGELOG.md +42 -0
- package/README.md +53 -1
- package/dist/handlers/environmentHandler.js +15 -0
- package/dist/handlers/environmentSessionsHandler.js +60 -0
- package/dist/handlers/probePageHandler.js +252 -139
- package/dist/handlers/runTestSuiteHandler.js +70 -51
- package/dist/handlers/testPageChangesHandler.js +33 -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/index.js +31 -0
- package/dist/services/ngrok/tunnelManager.js +526 -625
- package/dist/services/ngrok/tunnelRegistry.js +57 -70
- package/dist/tools/environment.js +9 -3
- package/dist/tools/testPageChanges.js +4 -0
- package/dist/types/index.js +11 -0
- package/dist/utils/confirmDestructive.js +32 -6
- 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
|
@@ -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
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
// ──
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
//
|
|
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
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
let
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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:
|
|
290
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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(
|
|
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
|
|
317
|
-
|
|
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,
|
|
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
|
}
|