@debugg-ai/debugg-ai-mcp 2.4.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -1
- package/dist/handlers/index.js +1 -0
- package/dist/handlers/probePageHandler.js +314 -0
- package/dist/handlers/searchEnvironmentsHandler.js +12 -2
- package/dist/handlers/testPageChangesHandler.js +149 -70
- package/dist/handlers/triggerCrawlHandler.js +65 -21
- package/dist/services/ngrok/tunnelManager.js +46 -7
- package/dist/services/ngrok/tunnelRegistry.js +39 -5
- package/dist/services/ngrok/types.js +0 -1
- package/dist/tools/index.js +3 -0
- package/dist/tools/probePage.js +89 -0
- package/dist/types/index.js +17 -0
- package/dist/utils/errors.js +0 -1
- package/dist/utils/harSummarizer.js +193 -0
- package/dist/utils/projectAnalyzer.js +2 -2
- package/dist/utils/telemetry.js +1 -0
- package/dist/utils/transientErrors.js +82 -0
- package/dist/utils/urlParser.js +1 -1
- package/dist/utils/validation.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ docker run -i --rm --init -e DEBUGGAI_API_KEY=your_api_key quinnosha/debugg-ai-m
|
|
|
34
34
|
|
|
35
35
|
## Tools
|
|
36
36
|
|
|
37
|
-
The server exposes **
|
|
37
|
+
The server exposes **12** tools grouped into Browser (3), Search (3), Projects (3), and Environments (3). The headline tools are `check_app_in_browser` (full AI agent) and `probe_page` (lightweight no-LLM page probe); the rest manage projects, environments + their credentials, and execution history through a uniform `search_*` + CRUD pattern.
|
|
38
38
|
|
|
39
39
|
### Browser
|
|
40
40
|
|
|
@@ -75,6 +75,26 @@ URLs are short-lived presigned S3 — refetch the parent execution via `search_e
|
|
|
75
75
|
|
|
76
76
|
Fires a server-side browser-agent crawl to populate the project's knowledge graph. Localhost URLs tunnel automatically. Returns `{executionId, status, targetUrl, durationMs, outcome?, crawlSummary?, knowledgeGraph?, browserSession?}` with `knowledgeGraph.imported === true` on successful ingestion. The `browserSession` block (HAR + console-log URLs, same shape as above) is also present on completed crawls.
|
|
77
77
|
|
|
78
|
+
#### `probe_page`
|
|
79
|
+
|
|
80
|
+
**Lightweight no-LLM batch page probe.** Pass 1-20 URLs; each navigates, waits for load, and returns rendered state — screenshot + page metadata + structured console errors + network summary. No agent loop, no LLM cost, no scenario assertions. Use it for "did I just break /settings?", multi-route smoke after a refactor, CI per-PR sweeps, and quick is-it-up checks where `check_app_in_browser`'s 60-150s agent loop is overkill.
|
|
81
|
+
|
|
82
|
+
| Parameter | Type | Description |
|
|
83
|
+
|-----------|------|-------------|
|
|
84
|
+
| `targets` | array **required** | 1-20 entries: `[{url, waitForSelector?, waitForLoadState?, timeoutMs?}]` |
|
|
85
|
+
| `targets[].url` | string **required** | Public URL or localhost (auto-tunneled) |
|
|
86
|
+
| `targets[].waitForLoadState` | enum | `'load'` (default) / `'domcontentloaded'` / `'networkidle'` |
|
|
87
|
+
| `targets[].waitForSelector` | string | Optional CSS selector to wait for after navigation |
|
|
88
|
+
| `targets[].timeoutMs` | number | Per-URL timeout, 1000-30000 (default 10000) |
|
|
89
|
+
| `includeHtml` | boolean | Return raw HTML in each result (default false) |
|
|
90
|
+
| `captureScreenshots` | boolean | Return one PNG per target (default true) |
|
|
91
|
+
|
|
92
|
+
The whole batch shares a single backend execution + browser session + tunnel — 5 URLs in one call is dramatically faster than 5 parallel single-URL calls. Per-URL `error` field preserves batch resilience: a single failed target doesn't fail the others.
|
|
93
|
+
|
|
94
|
+
**`networkSummary` aggregation key is `origin + pathname`** — refetch loops (`?n=0..4` repeatedly hitting the same endpoint) collapse into a single entry with the count, so `/api/poll` showing up with `count: 47` is the actionable "infinite refetch loop" signal users originally asked for.
|
|
95
|
+
|
|
96
|
+
Performance budget: <10s for 1 URL, <25s for 20. Localhost dead-port returns `LocalServerUnreachable` in <2s without burning a workflow execution.
|
|
97
|
+
|
|
78
98
|
### Search (dual-mode: uuid detail OR filtered list)
|
|
79
99
|
|
|
80
100
|
Each `search_*` tool has two modes. Pass `{uuid}` for a single-record detail response. Pass filter params for a paginated summary list. 404 from the backend surfaces as `isError: true` with `{error: 'NotFound', message, uuid}`.
|
package/dist/handlers/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export * from './testPageChangesHandler.js';
|
|
2
2
|
export * from './triggerCrawlHandler.js';
|
|
3
|
+
export * from './probePageHandler.js';
|
|
3
4
|
export * from './searchProjectsHandler.js';
|
|
4
5
|
export * from './searchEnvironmentsHandler.js';
|
|
5
6
|
export * from './searchExecutionsHandler.js';
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* probePageHandler — lightweight no-LLM batch page probe.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors triggerCrawlHandler's 4-step pattern (find template → execute →
|
|
5
|
+
* poll → format response) but: (a) takes a list of targets and produces a
|
|
6
|
+
* list of results, (b) does no agent steps (zero LLM in critical path),
|
|
7
|
+
* (c) MCP-side aggregates per-target HAR slices into NetworkSummary[].
|
|
8
|
+
*
|
|
9
|
+
* The backend "Page Probe" workflow template runs:
|
|
10
|
+
* browser.setup → loop[targets](browser.navigate → browser.capture) → done
|
|
11
|
+
*
|
|
12
|
+
* Each browser.capture node emits per-iteration outputData with consoleSlice
|
|
13
|
+
* + harSlice windowed to that URL's load span — that's what makes per-URL
|
|
14
|
+
* networkSummary attribution accurate.
|
|
15
|
+
*/
|
|
16
|
+
import { config } from '../config/index.js';
|
|
17
|
+
import { Logger } from '../utils/logger.js';
|
|
18
|
+
import { handleExternalServiceError } from '../utils/errors.js';
|
|
19
|
+
import { DebuggAIServerClient } from '../services/index.js';
|
|
20
|
+
import { TunnelProvisionError } from '../services/tunnels.js';
|
|
21
|
+
import { tunnelManager } from '../services/ngrok/tunnelManager.js';
|
|
22
|
+
import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js';
|
|
23
|
+
import { extractLocalhostPort } from '../utils/urlParser.js';
|
|
24
|
+
import { buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
|
|
25
|
+
import { getCachedTemplateUuid, invalidateTemplateCache } from '../utils/handlerCaches.js';
|
|
26
|
+
import { reaggregateByOriginPath, mapConsoleSlice } from '../utils/harSummarizer.js';
|
|
27
|
+
const logger = new Logger({ module: 'probePageHandler' });
|
|
28
|
+
const TEMPLATE_KEYWORD = 'page probe';
|
|
29
|
+
export async function probePageHandler(input, context, rawProgressCallback) {
|
|
30
|
+
const startTime = Date.now();
|
|
31
|
+
logger.toolStart('probe_page', input);
|
|
32
|
+
// Bead 0bq: progress circuit-breaker — see testPageChangesHandler for rationale.
|
|
33
|
+
let progressDisabled = false;
|
|
34
|
+
const progressCallback = rawProgressCallback
|
|
35
|
+
? async (update) => {
|
|
36
|
+
if (progressDisabled)
|
|
37
|
+
return;
|
|
38
|
+
try {
|
|
39
|
+
await rawProgressCallback(update);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
progressDisabled = true;
|
|
43
|
+
logger.warn('Progress emission failed; disabling further emissions for this request', {
|
|
44
|
+
error: err instanceof Error ? err.message : String(err),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
: undefined;
|
|
49
|
+
const client = new DebuggAIServerClient(config.api.key);
|
|
50
|
+
await client.init();
|
|
51
|
+
const abortController = new AbortController();
|
|
52
|
+
const onStdinClose = () => {
|
|
53
|
+
abortController.abort();
|
|
54
|
+
progressDisabled = true;
|
|
55
|
+
};
|
|
56
|
+
process.stdin.once('close', onStdinClose);
|
|
57
|
+
// Per-target tunnel contexts. Index aligns with input.targets[].
|
|
58
|
+
const targetContexts = [];
|
|
59
|
+
// Tunnel keys we provisioned this call (for cleanup if creation fails after key acquired).
|
|
60
|
+
const acquiredKeyIds = [];
|
|
61
|
+
// Progress budget: 1 pre-flight + 1 template + 1 execute + N per-target captures + 1 done
|
|
62
|
+
const TOTAL_STEPS = 3 + input.targets.length + 1;
|
|
63
|
+
let progressStep = 0;
|
|
64
|
+
try {
|
|
65
|
+
if (progressCallback) {
|
|
66
|
+
await progressCallback({ progress: ++progressStep, total: TOTAL_STEPS, message: `Pre-flight + tunnel setup (${input.targets.length} target${input.targets.length === 1 ? '' : 's'})...` });
|
|
67
|
+
}
|
|
68
|
+
// ── Per-target pre-flight + tunnel resolution ──────────────────────────
|
|
69
|
+
for (const target of input.targets) {
|
|
70
|
+
const ctx = buildContext(target.url);
|
|
71
|
+
if (ctx.isLocalhost) {
|
|
72
|
+
// Pre-flight TCP probe: fail fast if dev server isn't listening.
|
|
73
|
+
const port = extractLocalhostPort(ctx.originalUrl);
|
|
74
|
+
if (typeof port === 'number') {
|
|
75
|
+
const probe = await probeLocalPort(port);
|
|
76
|
+
if (!probe.reachable) {
|
|
77
|
+
const payload = {
|
|
78
|
+
error: 'LocalServerUnreachable',
|
|
79
|
+
message: `No server listening on 127.0.0.1:${port}. Start your dev server on that port before running probe_page. Probe result: ${probe.code} (${probe.detail ?? 'no detail'}).`,
|
|
80
|
+
detail: {
|
|
81
|
+
port,
|
|
82
|
+
probeCode: probe.code,
|
|
83
|
+
probeDetail: probe.detail,
|
|
84
|
+
elapsedMs: probe.elapsedMs,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
logger.warn(`Pre-flight port probe failed for ${ctx.originalUrl}: ${probe.code} in ${probe.elapsedMs}ms`);
|
|
88
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Reuse existing tunnel for this port if any; otherwise provision.
|
|
92
|
+
const reused = findExistingTunnel(ctx);
|
|
93
|
+
if (reused) {
|
|
94
|
+
targetContexts.push(reused);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
let tunnel;
|
|
98
|
+
try {
|
|
99
|
+
tunnel = await client.tunnels.provisionWithRetry();
|
|
100
|
+
}
|
|
101
|
+
catch (provisionError) {
|
|
102
|
+
const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
|
|
103
|
+
const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
|
|
104
|
+
throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}. ` +
|
|
105
|
+
`(Detail: ${msg})${diag}`);
|
|
106
|
+
}
|
|
107
|
+
acquiredKeyIds.push(tunnel.keyId);
|
|
108
|
+
let tunneled;
|
|
109
|
+
try {
|
|
110
|
+
tunneled = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
|
|
111
|
+
}
|
|
112
|
+
catch (tunnelError) {
|
|
113
|
+
const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
|
|
114
|
+
throw new Error(`Tunnel creation failed for ${ctx.originalUrl}. (Detail: ${msg})`);
|
|
115
|
+
}
|
|
116
|
+
// Tunnel health probe: catch the IPv4/IPv6 bind / dead-server case
|
|
117
|
+
// before committing to a full backend execution.
|
|
118
|
+
if (tunneled.targetUrl) {
|
|
119
|
+
const health = await probeTunnelHealth(tunneled.targetUrl);
|
|
120
|
+
if (!health.healthy) {
|
|
121
|
+
const payload = {
|
|
122
|
+
error: 'TunnelTrafficBlocked',
|
|
123
|
+
message: `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`,
|
|
124
|
+
detail: {
|
|
125
|
+
code: health.code,
|
|
126
|
+
status: health.status,
|
|
127
|
+
ngrokErrorCode: health.ngrokErrorCode,
|
|
128
|
+
elapsedMs: health.elapsedMs,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
if (tunneled.tunnelId) {
|
|
132
|
+
tunnelManager.stopTunnel(tunneled.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${tunneled.tunnelId}: ${err}`));
|
|
133
|
+
}
|
|
134
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
targetContexts.push(tunneled);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
// Public URL — no tunnel needed.
|
|
142
|
+
targetContexts.push(ctx);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// ── Locate workflow template ───────────────────────────────────────────
|
|
146
|
+
if (progressCallback) {
|
|
147
|
+
await progressCallback({ progress: ++progressStep, total: TOTAL_STEPS, message: 'Locating page-probe workflow template...' });
|
|
148
|
+
}
|
|
149
|
+
const templateUuid = await getCachedTemplateUuid(TEMPLATE_KEYWORD, async (name) => {
|
|
150
|
+
return client.workflows.findTemplateByName(name);
|
|
151
|
+
});
|
|
152
|
+
if (!templateUuid) {
|
|
153
|
+
throw new Error(`Page Probe Workflow Template not found. ` +
|
|
154
|
+
`Ensure the backend has a template matching "${TEMPLATE_KEYWORD}" seeded and accessible.`);
|
|
155
|
+
}
|
|
156
|
+
// ── Build contextData (camelCase; axiosTransport snake_cases on the wire) ──
|
|
157
|
+
// Backend's browser.setup node (shared with App Evaluation + Raw Crawl
|
|
158
|
+
// templates) requires `target_url` (singular). The Page Probe template
|
|
159
|
+
// currently uses that node as-is — the per-target loop primitive is
|
|
160
|
+
// pending. Send BOTH:
|
|
161
|
+
// - targetUrl: first target's tunneled URL (satisfies browser.setup
|
|
162
|
+
// today; will keep working when the loop wraps it later)
|
|
163
|
+
// - targets[]: the full per-URL config for when the loop primitive
|
|
164
|
+
// ships and iterates over them
|
|
165
|
+
const firstTargetUrl = targetContexts[0]?.targetUrl ?? input.targets[0].url;
|
|
166
|
+
const contextData = {
|
|
167
|
+
targetUrl: firstTargetUrl,
|
|
168
|
+
targets: input.targets.map((t, i) => ({
|
|
169
|
+
url: targetContexts[i].targetUrl ?? t.url,
|
|
170
|
+
// Send null (not undefined) for optional fields so the field exists
|
|
171
|
+
// in the target object even when the caller didn't pass one. Backend
|
|
172
|
+
// placeholder resolver was fixed in commit 154e1e69 to type-preserve
|
|
173
|
+
// null in single-placeholder substitutions, so null flows through.
|
|
174
|
+
waitForSelector: t.waitForSelector ?? null,
|
|
175
|
+
waitForLoadState: t.waitForLoadState,
|
|
176
|
+
timeoutMs: t.timeoutMs,
|
|
177
|
+
})),
|
|
178
|
+
// Backend's browser.capture template binds {{include_dom}} and
|
|
179
|
+
// {{include_screenshot}} from contextData (verified 2026-04-29).
|
|
180
|
+
// The MCP-facing schema keeps `includeHtml` / `captureScreenshots`
|
|
181
|
+
// for caller ergonomics; we just map them to what the template wants.
|
|
182
|
+
includeDom: input.includeHtml,
|
|
183
|
+
includeScreenshot: input.captureScreenshots,
|
|
184
|
+
// Keep the original keys too for any downstream node that reads them
|
|
185
|
+
// (cheap to send, future-proof against template field-name churn).
|
|
186
|
+
includeHtml: input.includeHtml,
|
|
187
|
+
captureScreenshots: input.captureScreenshots,
|
|
188
|
+
};
|
|
189
|
+
// ── Execute ────────────────────────────────────────────────────────────
|
|
190
|
+
if (progressCallback) {
|
|
191
|
+
await progressCallback({ progress: ++progressStep, total: TOTAL_STEPS, message: 'Queuing workflow execution...' });
|
|
192
|
+
}
|
|
193
|
+
const executeResponse = await client.workflows.executeWorkflow(templateUuid, contextData);
|
|
194
|
+
const executionUuid = executeResponse.executionUuid;
|
|
195
|
+
logger.info(`Probe execution queued: ${executionUuid}`);
|
|
196
|
+
// ── Poll ───────────────────────────────────────────────────────────────
|
|
197
|
+
let lastCompleted = -1;
|
|
198
|
+
const finalExecution = await client.workflows.pollExecution(executionUuid, async (exec) => {
|
|
199
|
+
// Keep all active tunnels alive during polling.
|
|
200
|
+
for (const tc of targetContexts) {
|
|
201
|
+
if (tc.tunnelId)
|
|
202
|
+
touchTunnelById(tc.tunnelId);
|
|
203
|
+
}
|
|
204
|
+
if (!progressCallback)
|
|
205
|
+
return;
|
|
206
|
+
const completedNodes = (exec.nodeExecutions ?? []).filter(n => n.nodeType === 'browser.capture' && n.status === 'success').length;
|
|
207
|
+
if (completedNodes !== lastCompleted) {
|
|
208
|
+
lastCompleted = completedNodes;
|
|
209
|
+
await progressCallback({
|
|
210
|
+
progress: Math.min(progressStep + completedNodes, TOTAL_STEPS - 1),
|
|
211
|
+
total: TOTAL_STEPS,
|
|
212
|
+
message: `Probed ${completedNodes}/${input.targets.length} target${input.targets.length === 1 ? '' : 's'}...`,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}, abortController.signal);
|
|
216
|
+
// ── Format response ────────────────────────────────────────────────────
|
|
217
|
+
const duration = Date.now() - startTime;
|
|
218
|
+
const captureNodes = (finalExecution.nodeExecutions ?? [])
|
|
219
|
+
.filter(n => n.nodeType === 'browser.capture')
|
|
220
|
+
.sort((a, b) => a.executionOrder - b.executionOrder);
|
|
221
|
+
const results = [];
|
|
222
|
+
for (let i = 0; i < input.targets.length; i++) {
|
|
223
|
+
const target = input.targets[i];
|
|
224
|
+
const node = captureNodes[i];
|
|
225
|
+
const data = node?.outputData ?? {};
|
|
226
|
+
// Backend (post-154e1e69) emits browser.capture output_data with:
|
|
227
|
+
// captured_url, status_code, title, load_time_ms,
|
|
228
|
+
// console_slice (already per-capture, in {text, level, location, timestamp} shape),
|
|
229
|
+
// network_summary (already pre-aggregated by FULL URL,
|
|
230
|
+
// in {url, count, methods[], statuses{}, resource_types[]} shape),
|
|
231
|
+
// surfer_page_uuid (reference to SurferPage row for screenshot/title/visible_text),
|
|
232
|
+
// error
|
|
233
|
+
// axiosTransport snake→camel'd at the wire, so JS-side these are
|
|
234
|
+
// capturedUrl / consoleSlice / networkSummary / surferPageUuid / etc.
|
|
235
|
+
// Re-aggregate networkSummary by origin+pathname so refetch loops
|
|
236
|
+
// collapse (preserves the original client-feedback contract).
|
|
237
|
+
const result = {
|
|
238
|
+
url: target.url, // ORIGINAL caller URL — not the tunneled rewrite
|
|
239
|
+
finalUrl: typeof data.capturedUrl === 'string' ? data.capturedUrl
|
|
240
|
+
: typeof data.finalUrl === 'string' ? data.finalUrl
|
|
241
|
+
: typeof data.url === 'string' ? data.url
|
|
242
|
+
: target.url,
|
|
243
|
+
statusCode: typeof data.statusCode === 'number' ? data.statusCode : 0,
|
|
244
|
+
title: typeof data.title === 'string' ? data.title : null,
|
|
245
|
+
loadTimeMs: typeof data.loadTimeMs === 'number' ? data.loadTimeMs : 0,
|
|
246
|
+
consoleErrors: mapConsoleSlice(Array.isArray(data.consoleSlice) ? data.consoleSlice : []),
|
|
247
|
+
networkSummary: reaggregateByOriginPath(Array.isArray(data.networkSummary) ? data.networkSummary : []),
|
|
248
|
+
};
|
|
249
|
+
if (input.includeHtml && typeof data.html === 'string') {
|
|
250
|
+
result.html = data.html;
|
|
251
|
+
}
|
|
252
|
+
if (typeof data.error === 'string' && data.error) {
|
|
253
|
+
result.error = data.error;
|
|
254
|
+
}
|
|
255
|
+
if (typeof data.surferPageUuid === 'string' && data.surferPageUuid) {
|
|
256
|
+
result.surferPageUuid = data.surferPageUuid;
|
|
257
|
+
}
|
|
258
|
+
results.push(result);
|
|
259
|
+
// Backend stores screenshots on the SurferPage row referenced by
|
|
260
|
+
// surfer_page_uuid; the inline screenshotB64 is no longer in capture
|
|
261
|
+
// output. v1: skip the per-result image content block; the screenshot
|
|
262
|
+
// is reachable via search_executions detail's surfer_page_uuid → SurferPage.
|
|
263
|
+
// (Future enhancement: fetch the SurferPage's presigned screenshot_url
|
|
264
|
+
// when input.captureScreenshots is true.)
|
|
265
|
+
}
|
|
266
|
+
const responsePayload = {
|
|
267
|
+
executionId: executionUuid,
|
|
268
|
+
durationMs: typeof finalExecution.durationMs === 'number' ? finalExecution.durationMs : duration,
|
|
269
|
+
results,
|
|
270
|
+
};
|
|
271
|
+
if (finalExecution.browserSession) {
|
|
272
|
+
responsePayload.browserSession = finalExecution.browserSession;
|
|
273
|
+
}
|
|
274
|
+
// Sanitize ngrok URLs from the entire payload — agent-authored strings in
|
|
275
|
+
// node outputData (titles, HTML, console messages from the page itself)
|
|
276
|
+
// can occasionally contain the tunnel URL; rewrite to the original
|
|
277
|
+
// localhost origin per tunnel context. For multi-localhost batches we
|
|
278
|
+
// run sanitize once per localhost target since each may have its own
|
|
279
|
+
// tunnel↔origin mapping.
|
|
280
|
+
let sanitizedPayload = responsePayload;
|
|
281
|
+
for (const tc of targetContexts) {
|
|
282
|
+
if (tc.isLocalhost) {
|
|
283
|
+
sanitizedPayload = sanitizeResponseUrls(sanitizedPayload, tc);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
logger.toolComplete('probe_page', duration);
|
|
287
|
+
return {
|
|
288
|
+
content: [
|
|
289
|
+
{ type: 'text', text: JSON.stringify(sanitizedPayload, null, 2) },
|
|
290
|
+
],
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
const duration = Date.now() - startTime;
|
|
295
|
+
logger.toolError('probe_page', error, duration);
|
|
296
|
+
if (error instanceof Error && (error.message.includes('not found') || error.message.includes('401'))) {
|
|
297
|
+
invalidateTemplateCache();
|
|
298
|
+
}
|
|
299
|
+
throw handleExternalServiceError(error, 'DebuggAI', 'probe_page execution');
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
process.stdin.removeListener('close', onStdinClose);
|
|
303
|
+
// Tunnels intentionally NOT torn down — reuse pattern (bead vwd) +
|
|
304
|
+
// 55-min idle auto-shutoff. Revoke only orphaned keys (we acquired the
|
|
305
|
+
// key but tunnel creation failed before ensureTunnel completed).
|
|
306
|
+
for (let i = 0; i < acquiredKeyIds.length; i++) {
|
|
307
|
+
const keyId = acquiredKeyIds[i];
|
|
308
|
+
const tc = targetContexts[i];
|
|
309
|
+
if (tc && !tc.tunnelId && keyId) {
|
|
310
|
+
client.revokeNgrokKey(keyId).catch(err => logger.warn(`Failed to revoke unused ngrok key ${keyId}: ${err}`));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -61,6 +61,11 @@ export async function searchEnvironmentsHandler(input, _context) {
|
|
|
61
61
|
const client = new DebuggAIServerClient(config.api.key);
|
|
62
62
|
await client.init();
|
|
63
63
|
// ── Resolve projectUuid ──
|
|
64
|
+
// Bead gb4n: when projectUuid is provided directly (caller skips git
|
|
65
|
+
// auto-resolution), `name` and `repoName` are unknown. OMIT those fields
|
|
66
|
+
// rather than emitting nulls — null fields surprised callers and
|
|
67
|
+
// muddied the contract. If a caller needs them, they fetch via
|
|
68
|
+
// search_projects.
|
|
64
69
|
let projectUuid = input.projectUuid;
|
|
65
70
|
let project = null;
|
|
66
71
|
if (!projectUuid) {
|
|
@@ -73,10 +78,15 @@ export async function searchEnvironmentsHandler(input, _context) {
|
|
|
73
78
|
return noProjectResolved(pagination, `No DebuggAI project found for repo "${repoName}". Pass projectUuid explicitly.`);
|
|
74
79
|
}
|
|
75
80
|
projectUuid = resolved.uuid;
|
|
76
|
-
project = { uuid: resolved.uuid
|
|
81
|
+
project = { uuid: resolved.uuid };
|
|
82
|
+
if (resolved.name)
|
|
83
|
+
project.name = resolved.name;
|
|
84
|
+
const rn = resolved.repo?.name ?? repoName;
|
|
85
|
+
if (rn)
|
|
86
|
+
project.repoName = rn;
|
|
77
87
|
}
|
|
78
88
|
else {
|
|
79
|
-
project = { uuid: projectUuid
|
|
89
|
+
project = { uuid: projectUuid };
|
|
80
90
|
}
|
|
81
91
|
// ── uuid mode ──
|
|
82
92
|
if (input.uuid) {
|