@debugg-ai/debugg-ai-mcp 6.1.5 → 6.1.6
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 +4 -0
- package/dist/handlers/localProbeHandler.js +46 -0
- package/dist/handlers/probePageHandler.js +4 -0
- package/dist/services/localBrowser.js +266 -0
- package/dist/services/workflows.js +26 -1
- package/dist/tools/probePage.js +22 -0
- package/dist/types/index.js +10 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -47,6 +47,10 @@ cause), then that your dev server answers a plain `curl http://127.0.0.1:<port>/
|
|
|
47
47
|
Verified 2026-11-10: servers bound to `127.0.0.1` only (IPv4 Caddy gateways on :4040 and :4043) probe fine via
|
|
48
48
|
`http://localhost:<port>` and `http://127.0.0.1:<port>`.
|
|
49
49
|
|
|
50
|
+
### Local Chrome capture (opt-in)
|
|
51
|
+
|
|
52
|
+
`probe_page` accepts `browser: "local"` to capture with Chrome on your own machine: no tunnel, no cloud browser, nothing uploaded (screenshots also saved under `~/.debugg-ai/captures`, mode 0600). The server owner must set `DEBUGGAI_LOCAL_BROWSER=1`; Chrome is found automatically or via `DEBUGGAI_CHROME_PATH`; `playwright-core` is an optional dependency. `useProfile: true` uses an MCP-owned persistent profile (`~/.debugg-ai/chrome-profile`, never your everyday Chrome profile): sign in once with `headless: false, loginWindowMs: 120000`, later local probes are logged in. Profile sessions require `allowedDomains` (or the owner's `DEBUGGAI_PROFILE_ALLOWED_DOMAINS`, which overrides it); top-level navigations off the list, redirect hops included, are blocked.
|
|
53
|
+
|
|
50
54
|
Get an API key at [debugg.ai](https://debugg.ai), then add to your MCP client config:
|
|
51
55
|
|
|
52
56
|
```json
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* probe_page with browser:'local' (bead jpw3.1/.2): capture each target with the
|
|
3
|
+
* user's own Chrome on this machine. No backend call, no tunnel.
|
|
4
|
+
*/
|
|
5
|
+
import { localCapture, LocalBrowserError } from '../services/localBrowser.js';
|
|
6
|
+
import { imageContentBlock } from '../utils/imageUtils.js';
|
|
7
|
+
import { logger } from '../utils/logger.js';
|
|
8
|
+
export async function localProbe(input, context) {
|
|
9
|
+
const results = [];
|
|
10
|
+
const images = [];
|
|
11
|
+
for (const t of input.targets) {
|
|
12
|
+
try {
|
|
13
|
+
const r = await localCapture({
|
|
14
|
+
url: t.url,
|
|
15
|
+
headless: input.headless,
|
|
16
|
+
useProfile: input.useProfile,
|
|
17
|
+
loginWindowMs: input.loginWindowMs,
|
|
18
|
+
allowedDomains: input.allowedDomains,
|
|
19
|
+
waitForSelector: t.waitForSelector,
|
|
20
|
+
waitForLoadState: t.waitForLoadState,
|
|
21
|
+
timeoutMs: t.timeoutMs,
|
|
22
|
+
includeHtml: input.includeHtml,
|
|
23
|
+
captureScreenshot: input.captureScreenshots,
|
|
24
|
+
signal: context.signal,
|
|
25
|
+
});
|
|
26
|
+
const { screenshotBase64, ...rest } = r;
|
|
27
|
+
results.push(rest);
|
|
28
|
+
if (screenshotBase64)
|
|
29
|
+
images.push(imageContentBlock(screenshotBase64, 'image/png'));
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
if (err instanceof LocalBrowserError && (err.code === 'LOCAL_BROWSER_DISABLED' || err.code === 'PLAYWRIGHT_MISSING' || err.code === 'PROFILE_ALLOWLIST_REQUIRED')) {
|
|
33
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: err.code, message: err.message }, null, 2) }], isError: true };
|
|
34
|
+
}
|
|
35
|
+
const message = err instanceof Error ? err.message.split('\n')[0] : String(err);
|
|
36
|
+
logger.warn(`probe_page local: ${t.url} failed: ${message}`);
|
|
37
|
+
results.push({ url: t.url, error: message, ...(err instanceof LocalBrowserError ? { errorCode: err.code } : {}) });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
content: [
|
|
42
|
+
{ type: 'text', text: JSON.stringify({ browser: 'local', results }, null, 2) },
|
|
43
|
+
...images,
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -25,6 +25,7 @@ import { buildContext, findExistingTunnel, ensureTunnel, acquirePortRoute, relea
|
|
|
25
25
|
import { randomUUID } from 'node:crypto';
|
|
26
26
|
import { getCachedTemplateUuid, invalidateTemplateCache } from '../utils/handlerCaches.js';
|
|
27
27
|
import { getPageProbeTemplateSlug } from '../services/workflows.js';
|
|
28
|
+
import { localProbe } from './localProbeHandler.js';
|
|
28
29
|
import { reaggregateByOriginPath, mapConsoleSlice } from '../utils/harSummarizer.js';
|
|
29
30
|
import { fetchImageAsBase64, imageContentBlock } from '../utils/imageUtils.js';
|
|
30
31
|
const logger = new Logger({ module: 'probePageHandler' });
|
|
@@ -76,6 +77,9 @@ export async function probePageHandler(input, context, rawProgressCallback) {
|
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
: undefined;
|
|
80
|
+
if (input.browser === 'local') {
|
|
81
|
+
return localProbe(input, context);
|
|
82
|
+
}
|
|
79
83
|
const client = new DebuggAIServerClient(config.api.key);
|
|
80
84
|
await client.init();
|
|
81
85
|
// Bead 56kd.7: cancellation is driven by the MCP request/transport lifecycle
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Chrome capture for probe_page (bead jpw3.1 / jpw3.2).
|
|
3
|
+
*
|
|
4
|
+
* Launches the user's installed Chrome through playwright-core on THIS machine
|
|
5
|
+
* and captures a page directly — no tunnel, no cloud browser, nothing uploaded.
|
|
6
|
+
* Everything stays local: screenshots are returned inline and also written under
|
|
7
|
+
* ~/.debugg-ai/captures (0600).
|
|
8
|
+
*
|
|
9
|
+
* Opt-in at the server: DEBUGGAI_LOCAL_BROWSER=1. With `useProfile`, the browser
|
|
10
|
+
* uses an MCP-owned persistent profile (~/.debugg-ai/chrome-profile, override with
|
|
11
|
+
* DEBUGGAI_CHROME_PROFILE_DIR) the user logs into once via `loginToLocalProfile`;
|
|
12
|
+
* the user's everyday Chrome profile is never touched.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
export class LocalBrowserError extends Error {
|
|
18
|
+
code;
|
|
19
|
+
constructor(message, code) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.name = 'LocalBrowserError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Host matches an allowed domain exactly or as a subdomain. */
|
|
26
|
+
export function hostAllowed(host, allowed) {
|
|
27
|
+
const h = host.toLowerCase();
|
|
28
|
+
return allowed.some((d) => {
|
|
29
|
+
const a = d.trim().toLowerCase().replace(/^\*?\./, '');
|
|
30
|
+
return a !== '' && (h === a || h.endsWith(`.${a}`));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Allowlist for a profile session (bead jpw3.3). The server owner's
|
|
35
|
+
* DEBUGGAI_PROFILE_ALLOWED_DOMAINS (comma list) is authoritative when set;
|
|
36
|
+
* otherwise the call must name allowedDomains. Never empty for a profile run.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveAllowedDomains(perCall, env = process.env) {
|
|
39
|
+
const owner = (env.DEBUGGAI_PROFILE_ALLOWED_DOMAINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
40
|
+
return owner.length > 0 ? owner : (perCall ?? []).filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
export function localBrowserEnabled(env = process.env) {
|
|
43
|
+
return env.DEBUGGAI_LOCAL_BROWSER === '1' || env.DEBUGGAI_LOCAL_BROWSER === 'true';
|
|
44
|
+
}
|
|
45
|
+
export function debuggHome(env = process.env) {
|
|
46
|
+
return env.DEBUGGAI_HOME || path.join(os.homedir(), '.debugg-ai');
|
|
47
|
+
}
|
|
48
|
+
export function profileDir(env = process.env) {
|
|
49
|
+
return env.DEBUGGAI_CHROME_PROFILE_DIR || path.join(debuggHome(env), 'chrome-profile');
|
|
50
|
+
}
|
|
51
|
+
/** Well-known Chrome locations; DEBUGGAI_CHROME_PATH wins. undefined => let playwright try channel 'chrome'. */
|
|
52
|
+
export function findChrome(env = process.env) {
|
|
53
|
+
if (env.DEBUGGAI_CHROME_PATH)
|
|
54
|
+
return env.DEBUGGAI_CHROME_PATH;
|
|
55
|
+
const candidates = process.platform === 'darwin'
|
|
56
|
+
? ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium']
|
|
57
|
+
: process.platform === 'win32'
|
|
58
|
+
? [
|
|
59
|
+
`${env.PROGRAMFILES ?? 'C:\\Program Files'}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
60
|
+
`${env['PROGRAMFILES(X86)'] ?? 'C:\\Program Files (x86)'}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
61
|
+
]
|
|
62
|
+
: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser'];
|
|
63
|
+
return candidates.find((p) => fs.existsSync(p));
|
|
64
|
+
}
|
|
65
|
+
async function loadPlaywright() {
|
|
66
|
+
try {
|
|
67
|
+
return await import('playwright-core');
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new LocalBrowserError('Local browser mode needs the optional dependency playwright-core, which is not installed. Run `npm i playwright-core` next to the MCP.', 'PLAYWRIGHT_MISSING');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function safeHost(u) {
|
|
74
|
+
try {
|
|
75
|
+
return new URL(u).hostname;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return u;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function ensurePrivateDir(dir) {
|
|
82
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
83
|
+
}
|
|
84
|
+
async function launch(opts) {
|
|
85
|
+
const pw = await loadPlaywright();
|
|
86
|
+
const executablePath = findChrome();
|
|
87
|
+
const base = { headless: opts.headless, ...(executablePath ? { executablePath } : { channel: 'chrome' }) };
|
|
88
|
+
try {
|
|
89
|
+
if (opts.useProfile) {
|
|
90
|
+
const dir = profileDir();
|
|
91
|
+
ensurePrivateDir(dir);
|
|
92
|
+
const context = await pw.chromium.launchPersistentContext(dir, { ...base, viewport: { width: 1280, height: 800 } });
|
|
93
|
+
return { context, close: () => context.close() };
|
|
94
|
+
}
|
|
95
|
+
const browser = await pw.chromium.launch(base);
|
|
96
|
+
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
97
|
+
return { context, close: () => browser.close() };
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
101
|
+
if (/ProcessSingleton|SingletonLock|profile.*in use|already running/i.test(msg)) {
|
|
102
|
+
throw new LocalBrowserError(`The MCP's Chrome profile (${profileDir()}) is already open in another Chrome window. Close it and retry.`, 'PROFILE_LOCKED');
|
|
103
|
+
}
|
|
104
|
+
if (/Executable doesn't exist|Failed to launch|chrome.*not found|distribution/i.test(msg)) {
|
|
105
|
+
throw new LocalBrowserError(`Could not start Chrome (${executablePath ?? "channel 'chrome'"}). Install Google Chrome or set DEBUGGAI_CHROME_PATH. Detail: ${msg.split('\n')[0]}`, 'CHROME_NOT_FOUND');
|
|
106
|
+
}
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Aggregate responses by origin+path (query stripped), capped. */
|
|
111
|
+
function summarize(entries) {
|
|
112
|
+
const map = new Map();
|
|
113
|
+
for (const e of entries) {
|
|
114
|
+
let key = e.url;
|
|
115
|
+
try {
|
|
116
|
+
const u = new URL(e.url);
|
|
117
|
+
key = u.origin + u.pathname;
|
|
118
|
+
}
|
|
119
|
+
catch { /* keep raw */ }
|
|
120
|
+
const row = map.get(key) ?? { url: key, count: 0, statuses: {}, totalBytes: 0 };
|
|
121
|
+
row.count++;
|
|
122
|
+
row.statuses[String(e.status)] = (row.statuses[String(e.status)] ?? 0) + 1;
|
|
123
|
+
row.totalBytes += e.bytes;
|
|
124
|
+
map.set(key, row);
|
|
125
|
+
}
|
|
126
|
+
return [...map.values()].sort((a, b) => b.count - a.count).slice(0, 50);
|
|
127
|
+
}
|
|
128
|
+
export async function localCapture(o) {
|
|
129
|
+
if (!localBrowserEnabled()) {
|
|
130
|
+
throw new LocalBrowserError('Local browser mode is off. Set DEBUGGAI_LOCAL_BROWSER=1 in the MCP server environment to allow it.', 'LOCAL_BROWSER_DISABLED');
|
|
131
|
+
}
|
|
132
|
+
const allowed = o.useProfile ? resolveAllowedDomains(o.allowedDomains) : [];
|
|
133
|
+
if (o.useProfile && allowed.length === 0) {
|
|
134
|
+
throw new LocalBrowserError('A profile session needs an allowlist of domains it may visit: pass allowedDomains (e.g. ["example.com"]) or have the server owner set DEBUGGAI_PROFILE_ALLOWED_DOMAINS.', 'PROFILE_ALLOWLIST_REQUIRED');
|
|
135
|
+
}
|
|
136
|
+
const timeout = Math.min(Math.max(o.timeoutMs ?? 10000, 1000), 30000);
|
|
137
|
+
const { context, close } = await launch({ headless: o.headless !== false, useProfile: !!o.useProfile });
|
|
138
|
+
const onAbort = () => { void close().catch(() => { }); };
|
|
139
|
+
o.signal?.addEventListener('abort', onAbort, { once: true });
|
|
140
|
+
try {
|
|
141
|
+
const page = context.pages()[0] ?? await context.newPage();
|
|
142
|
+
if (o.useProfile) {
|
|
143
|
+
const isLocal = (h) => ['localhost', '127.0.0.1', '[::1]', '::1'].includes(h);
|
|
144
|
+
const startHost = (() => { try {
|
|
145
|
+
return new URL(o.url).hostname;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return '';
|
|
149
|
+
} })();
|
|
150
|
+
if (!hostAllowed(startHost, allowed) && !isLocal(startHost)) {
|
|
151
|
+
throw new LocalBrowserError(`${startHost || o.url} is not in the allowlist for this profile session.`, 'DOMAIN_NOT_ALLOWED');
|
|
152
|
+
}
|
|
153
|
+
// Block top-level DOCUMENT loads off the allowlist, redirect hops included, via
|
|
154
|
+
// CDP Fetch (Playwright's route() never sees server-side redirect hops, so it
|
|
155
|
+
// cannot enforce this). Sub-resources (CDNs) pass.
|
|
156
|
+
const guard = async (pg) => {
|
|
157
|
+
const cdp = await context.newCDPSession(pg);
|
|
158
|
+
const mainFrameId = (await cdp.send('Page.getFrameTree')).frameTree.frame.id;
|
|
159
|
+
await cdp.send('Fetch.enable', { patterns: [
|
|
160
|
+
{ urlPattern: '*', resourceType: 'Document', requestStage: 'Request' },
|
|
161
|
+
{ urlPattern: '*', resourceType: 'Document', requestStage: 'Response' },
|
|
162
|
+
] });
|
|
163
|
+
cdp.on('Fetch.requestPaused', (ev) => {
|
|
164
|
+
void (async () => {
|
|
165
|
+
const check = (u) => {
|
|
166
|
+
try {
|
|
167
|
+
const h = new URL(u).hostname;
|
|
168
|
+
return hostAllowed(h, allowed) || isLocal(h);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
try {
|
|
175
|
+
if (ev.frameId !== mainFrameId) {
|
|
176
|
+
await cdp.send(ev.responseStatusCode ? 'Fetch.continueResponse' : 'Fetch.continueRequest', { requestId: ev.requestId });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (ev.responseStatusCode === undefined) {
|
|
180
|
+
if (!check(ev.request.url)) {
|
|
181
|
+
blocked.push(safeHost(ev.request.url));
|
|
182
|
+
await cdp.send('Fetch.failRequest', { requestId: ev.requestId, errorReason: 'BlockedByClient' });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
await cdp.send('Fetch.continueRequest', { requestId: ev.requestId });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const loc = ev.responseHeaders?.find((h) => h.name.toLowerCase() === 'location')?.value;
|
|
189
|
+
if (ev.responseStatusCode >= 300 && ev.responseStatusCode < 400 && loc) {
|
|
190
|
+
const next = new URL(loc, ev.request.url).toString();
|
|
191
|
+
if (!check(next)) {
|
|
192
|
+
blocked.push(safeHost(next));
|
|
193
|
+
await cdp.send('Fetch.failRequest', { requestId: ev.requestId, errorReason: 'BlockedByClient' });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
await cdp.send('Fetch.continueResponse', { requestId: ev.requestId });
|
|
198
|
+
}
|
|
199
|
+
catch { /* page closed */ }
|
|
200
|
+
})();
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
await guard(page);
|
|
204
|
+
context.on('page', (pg) => { void guard(pg).catch(() => { }); });
|
|
205
|
+
}
|
|
206
|
+
const blocked = [];
|
|
207
|
+
const consoleErrors = [];
|
|
208
|
+
const net = [];
|
|
209
|
+
page.on('console', (m) => { if (m.type() === 'error' || m.type() === 'warning')
|
|
210
|
+
consoleErrors.push({ level: m.type(), text: m.text() }); });
|
|
211
|
+
page.on('pageerror', (e) => consoleErrors.push({ level: 'error', text: e.message }));
|
|
212
|
+
page.on('response', (r) => {
|
|
213
|
+
const len = Number(r.headers()['content-length'] ?? 0);
|
|
214
|
+
net.push({ url: r.url(), status: r.status(), bytes: Number.isFinite(len) ? len : 0 });
|
|
215
|
+
});
|
|
216
|
+
const t0 = Date.now();
|
|
217
|
+
let resp;
|
|
218
|
+
try {
|
|
219
|
+
resp = await page.goto(o.url, { waitUntil: o.waitForLoadState === 'load' ? 'load' : 'domcontentloaded', timeout });
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
if (blocked.length > 0) {
|
|
223
|
+
throw new LocalBrowserError(`Navigation to ${[...new Set(blocked)].join(', ')} was blocked: not in this profile session's allowlist.`, 'NAVIGATION_BLOCKED');
|
|
224
|
+
}
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
if (o.waitForSelector)
|
|
228
|
+
await page.waitForSelector(o.waitForSelector, { timeout: Math.max(1000, timeout - (Date.now() - t0)) });
|
|
229
|
+
else
|
|
230
|
+
await page.waitForLoadState('networkidle', { timeout: Math.min(2000, Math.max(500, timeout - (Date.now() - t0))) }).catch(() => { });
|
|
231
|
+
const result = {
|
|
232
|
+
url: o.url,
|
|
233
|
+
finalUrl: page.url() || null,
|
|
234
|
+
statusCode: resp ? resp.status() : null,
|
|
235
|
+
title: (await page.title().catch(() => '')) || null,
|
|
236
|
+
loadTimeMs: Date.now() - t0,
|
|
237
|
+
consoleErrors,
|
|
238
|
+
networkSummary: summarize(net),
|
|
239
|
+
};
|
|
240
|
+
if (blocked.length > 0)
|
|
241
|
+
result.blockedNavigations = [...new Set(blocked)];
|
|
242
|
+
if (o.loginWindowMs && o.useProfile && o.headless === false) {
|
|
243
|
+
const ms = Math.min(o.loginWindowMs, 600000);
|
|
244
|
+
await new Promise((resolve) => {
|
|
245
|
+
const t = setTimeout(resolve, ms);
|
|
246
|
+
context.once('close', () => { clearTimeout(t); resolve(); });
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
if (o.includeHtml)
|
|
250
|
+
result.html = await page.content();
|
|
251
|
+
if (o.captureScreenshot !== false) {
|
|
252
|
+
const buf = await page.screenshot({ type: 'png' });
|
|
253
|
+
result.screenshotBase64 = buf.toString('base64');
|
|
254
|
+
const dir = path.join(debuggHome(), 'captures');
|
|
255
|
+
ensurePrivateDir(dir);
|
|
256
|
+
const file = path.join(dir, `probe-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.png`);
|
|
257
|
+
fs.writeFileSync(file, buf, { mode: 0o600 });
|
|
258
|
+
result.screenshotPath = file;
|
|
259
|
+
}
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
o.signal?.removeEventListener('abort', onAbort);
|
|
264
|
+
await close().catch(() => { });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -3,12 +3,22 @@
|
|
|
3
3
|
* 4-step integration: find template → execute → poll → result
|
|
4
4
|
*/
|
|
5
5
|
import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
|
|
6
|
+
import { logger } from '../utils/logger.js';
|
|
6
7
|
import { redactRunSecrets } from '../utils/redactRunSecrets.js';
|
|
7
8
|
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']);
|
|
8
9
|
// Exponential backoff polling: short executions (10-15s crawls) detect terminal
|
|
9
10
|
// status quickly via the early polls; long executions (60-150s browser runs)
|
|
10
11
|
// avoid hammering the backend with 20+ roundtrips. Cap at 5s so we never wait
|
|
11
12
|
// more than 5s past terminal-state achievement.
|
|
13
|
+
const MAX_CONSECUTIVE_POLL_FAILURES = 5;
|
|
14
|
+
/** Network-class (no response), 5xx, 408 and 429 are worth another poll; any other 4xx is a real answer. */
|
|
15
|
+
function isTransientPollError(err) {
|
|
16
|
+
const e = err;
|
|
17
|
+
const status = e?.statusCode;
|
|
18
|
+
if (typeof status === 'number')
|
|
19
|
+
return status >= 500 || status === 408 || status === 429;
|
|
20
|
+
return true; // no status: network-class error (message-only), as axiosTransport shapes it
|
|
21
|
+
}
|
|
12
22
|
const POLL_INTERVAL_INITIAL_MS = 1000;
|
|
13
23
|
const POLL_INTERVAL_MAX_MS = 5000;
|
|
14
24
|
const POLL_BACKOFF_MULTIPLIER = 1.5;
|
|
@@ -128,11 +138,26 @@ export const createWorkflowsService = (tx) => {
|
|
|
128
138
|
// partial results (screenshot + trace) instead of discarding them (bead
|
|
129
139
|
// 56kd.3).
|
|
130
140
|
let lastExecution;
|
|
141
|
+
let consecutivePollFailures = 0;
|
|
131
142
|
while (Date.now() < deadline) {
|
|
132
143
|
if (signal?.aborted) {
|
|
133
144
|
throw new Error(`Polling cancelled for execution ${executionUuid}`);
|
|
134
145
|
}
|
|
135
|
-
|
|
146
|
+
let execution;
|
|
147
|
+
try {
|
|
148
|
+
execution = await service.getExecution(executionUuid);
|
|
149
|
+
consecutivePollFailures = 0;
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
// Bead kruv: one blip (ECONNRESET, 5xx during a deploy, 30s client
|
|
153
|
+
// timeout) must not lose a run the server is still executing. Only
|
|
154
|
+
// transient failures are tolerated, and only a few in a row.
|
|
155
|
+
if (!isTransientPollError(err) || ++consecutivePollFailures > MAX_CONSECUTIVE_POLL_FAILURES)
|
|
156
|
+
throw err;
|
|
157
|
+
logger.warn(`pollExecution: transient status-poll failure ${consecutivePollFailures}/${MAX_CONSECUTIVE_POLL_FAILURES} for ${executionUuid}: ${err instanceof Error ? err.message : err}`);
|
|
158
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
136
161
|
lastExecution = execution;
|
|
137
162
|
pollCount++;
|
|
138
163
|
if (onUpdate) {
|
package/dist/tools/probePage.js
CHANGED
|
@@ -81,6 +81,28 @@ export function buildProbePageTool() {
|
|
|
81
81
|
type: 'string',
|
|
82
82
|
description: "GitHub repository name (e.g. 'my-org/my-repo'). Auto-detected from the current git repo — only provide this to scope the probe to a different project context.",
|
|
83
83
|
},
|
|
84
|
+
browser: {
|
|
85
|
+
type: 'string',
|
|
86
|
+
enum: ['remote', 'local'],
|
|
87
|
+
description: "Default 'remote' (cloud browser; localhost is tunneled). 'local' runs Chrome on THIS machine against the URL directly: no tunnel, nothing uploaded, screenshot also saved under ~/.debugg-ai/captures. Works only when the MCP server runs on your machine AND its owner set DEBUGGAI_LOCAL_BROWSER=1. waitForSelector/waitForLoadState/timeoutMs apply; the local mode returns screenshot, console errors, network summary, title and (optionally) HTML.",
|
|
88
|
+
},
|
|
89
|
+
headless: {
|
|
90
|
+
type: 'boolean',
|
|
91
|
+
description: "browser:'local' only. Default true. false opens a visible Chrome window.",
|
|
92
|
+
},
|
|
93
|
+
useProfile: {
|
|
94
|
+
type: 'boolean',
|
|
95
|
+
description: "browser:'local' only. Default false (fresh throwaway profile). true uses the MCP-owned persistent profile (~/.debugg-ai/chrome-profile, never your everyday Chrome profile), so pages you signed into there are captured logged-in. Sign in once with headless:false plus loginWindowMs. Captures from it never leave the machine.",
|
|
96
|
+
},
|
|
97
|
+
allowedDomains: {
|
|
98
|
+
type: 'array',
|
|
99
|
+
items: { type: 'string' },
|
|
100
|
+
description: "browser:'local', useProfile:true only. REQUIRED for a profile session unless the server owner set DEBUGGAI_PROFILE_ALLOWED_DOMAINS (which then overrides this). Hostnames (subdomains included) the logged-in session may navigate to; any other top-level navigation, redirects included, is blocked and reported in blockedNavigations. localhost is always allowed.",
|
|
101
|
+
},
|
|
102
|
+
loginWindowMs: {
|
|
103
|
+
type: 'number',
|
|
104
|
+
description: "browser:'local', useProfile:true, headless:false only. Keep the window open up to this long (1000-600000 ms) or until you close it, so you can sign in to the site once.",
|
|
105
|
+
},
|
|
84
106
|
},
|
|
85
107
|
required: ['targets'],
|
|
86
108
|
additionalProperties: false,
|
package/dist/types/index.js
CHANGED
|
@@ -274,6 +274,16 @@ export const ProbePageInputSchema = z.object({
|
|
|
274
274
|
includeHtml: z.boolean().default(false),
|
|
275
275
|
captureScreenshots: z.boolean().default(true),
|
|
276
276
|
repoName: z.string().optional(),
|
|
277
|
+
// Local Chrome capture (beads jpw3.1/.2). 'remote' (default) is the cloud browser.
|
|
278
|
+
browser: z.enum(['remote', 'local']).default('remote'),
|
|
279
|
+
// local only: false opens a visible window.
|
|
280
|
+
headless: z.boolean().default(true),
|
|
281
|
+
// local only: use the MCP-owned persistent profile (log in once) instead of a throwaway one.
|
|
282
|
+
useProfile: z.boolean().default(false),
|
|
283
|
+
// local + useProfile + headless:false only: keep the window open this long so the user can sign in once.
|
|
284
|
+
loginWindowMs: z.number().int().min(1000).max(600000).optional(),
|
|
285
|
+
// local + useProfile: domains (and subdomains) the session may navigate to. Required unless the server owner set DEBUGGAI_PROFILE_ALLOWED_DOMAINS.
|
|
286
|
+
allowedDomains: z.array(z.string().min(1)).max(50).optional(),
|
|
277
287
|
}).strict();
|
|
278
288
|
// ── E2E Suite Management ──────────────────────────────────────────────────────
|
|
279
289
|
const projectIdentifier = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@debugg-ai/debugg-ai-mcp",
|
|
3
|
-
"version": "6.1.
|
|
3
|
+
"version": "6.1.6",
|
|
4
4
|
"description": "Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"caddy": "2.11.3",
|
|
@@ -83,5 +83,8 @@
|
|
|
83
83
|
"ts-jest": "^29.4.1",
|
|
84
84
|
"typescript": "^5.8.3",
|
|
85
85
|
"typescript-eslint": "^8.58.2"
|
|
86
|
+
},
|
|
87
|
+
"optionalDependencies": {
|
|
88
|
+
"playwright-core": "^1.65.0"
|
|
86
89
|
}
|
|
87
90
|
}
|