@debugg-ai/debugg-ai-mcp 6.1.4 → 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 +5 -0
- package/dist/handlers/localProbeHandler.js +46 -0
- package/dist/handlers/probePageHandler.js +4 -0
- package/dist/handlers/testPageChangesHandler.js +36 -3
- package/dist/services/caddy/caddyProxy.js +117 -5
- package/dist/services/localBrowser.js +266 -0
- package/dist/services/verdictAdapter.js +17 -0
- package/dist/services/workflows.js +26 -1
- package/dist/tools/probePage.js +22 -0
- package/dist/tools/testPageChanges.js +6 -0
- package/dist/types/index.js +15 -0
- package/dist/utils/artifactGrace.js +29 -0
- package/dist/utils/extraOrigins.js +57 -0
- package/dist/utils/tunnelContext.js +7 -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
|
|
@@ -102,6 +106,7 @@ Runs an AI browser agent against your app. The agent navigates, interacts, and r
|
|
|
102
106
|
| `loginCredentials` | array | Accounts for logins the agent hits **during** the task — `[{username, password, label?}]` |
|
|
103
107
|
| `useEnvironmentCredentials` | boolean | Default `true`. `false` forbids auto-filling the environment's stored credentials; with no account named it means **do not log in at all** |
|
|
104
108
|
| `freshSession` | boolean | Default `false`. `true` forces a real login instead of reusing the warm session held for that account |
|
|
109
|
+
| `extraOrigins` | string[] | Extra local origins the run may be redirected to, e.g. `["http://host.docker.internal:4717", "http://localhost:4304"]` (OAuth vendor + callback on other ports). Redirects to them (and back to `url`) are followed through the tunnel; pages on them are served at `/__o/<port>/`. Only listed origins are exposed. Absolute URLs written inside page HTML (not redirects) are not rewritten |
|
|
105
110
|
| `auth` | object | Auth precondition — `{precondition, entryUrl, deepUrl, environmentId, username, password}` |
|
|
106
111
|
| `repoName` | string | Override auto-detected git repo name (e.g. `my-org/my-repo`) |
|
|
107
112
|
|
|
@@ -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
|
|
@@ -10,7 +10,9 @@ import { handleExternalServiceError } from '../utils/errors.js';
|
|
|
10
10
|
import { fetchImageAsBase64, imageContentBlock, resourceLinkBlock, artifactResourceLinks } from '../utils/imageUtils.js';
|
|
11
11
|
import { DebuggAIServerClient } from '../services/index.js';
|
|
12
12
|
import { getEvalTemplateSlug } from '../services/workflows.js';
|
|
13
|
-
import {
|
|
13
|
+
import { authorizeExtraOrigins, ExtraOriginError } from '../utils/extraOrigins.js';
|
|
14
|
+
import { refreshPendingArtifacts } from '../utils/artifactGrace.js';
|
|
15
|
+
import { adaptVerdict, credentialSubstitutions, summarizeLogins } from '../services/verdictAdapter.js';
|
|
14
16
|
import { TunnelProvisionError } from '../services/tunnels.js';
|
|
15
17
|
import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, acquirePortRoute, releasePortRoute, sanitizeResponseUrls, touchTunnelById, retargetAuxiliaryUrl, } from '../utils/tunnelContext.js';
|
|
16
18
|
import { randomUUID } from 'node:crypto';
|
|
@@ -318,6 +320,18 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
318
320
|
}
|
|
319
321
|
: undefined,
|
|
320
322
|
});
|
|
323
|
+
// Bead w1os.2: extra local origins (OAuth vendor, callback) this run may
|
|
324
|
+
// be redirected to. Needs the route held; validated here so a bad entry
|
|
325
|
+
// fails fast with a plain message instead of mid-run.
|
|
326
|
+
try {
|
|
327
|
+
ctx = await authorizeExtraOrigins(ctx, input.extraOrigins);
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
if (err instanceof ExtraOriginError) {
|
|
331
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'InvalidExtraOrigin', message: err.message }, null, 2) }], isError: true };
|
|
332
|
+
}
|
|
333
|
+
throw err;
|
|
334
|
+
}
|
|
321
335
|
// Bead 1om: verify traffic actually flows through the tunnel. The
|
|
322
336
|
// tunnel can be established (connect returns OK) yet refuse
|
|
323
337
|
// to forward traffic — e.g., IPv4/IPv6 bind mismatch, or the dev
|
|
@@ -494,6 +508,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
494
508
|
let executeResponse;
|
|
495
509
|
let executionUuid = '';
|
|
496
510
|
let finalExecution;
|
|
511
|
+
let execStartedAtMs;
|
|
497
512
|
let attempt = 0;
|
|
498
513
|
while (true) {
|
|
499
514
|
attempt++;
|
|
@@ -516,6 +531,8 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
516
531
|
}
|
|
517
532
|
await new Promise(r => setTimeout(r, 1000 * (attempt - 1)));
|
|
518
533
|
}
|
|
534
|
+
const execStartedAt = Date.now();
|
|
535
|
+
execStartedAtMs = execStartedAt;
|
|
519
536
|
executeResponse = await client.workflows.executeWorkflow(templateUuid, contextData, Object.keys(env).length > 0 ? env : undefined);
|
|
520
537
|
executionUuid = executeResponse.executionUuid;
|
|
521
538
|
// Bead 5er7: this is now the execution an abort must cancel (a retry moves
|
|
@@ -626,6 +643,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
626
643
|
}
|
|
627
644
|
const duration = Date.now() - startTime;
|
|
628
645
|
// --- Format result ---
|
|
646
|
+
const runDoneAtMs = Date.now();
|
|
629
647
|
const nodes = finalExecution.nodeExecutions ?? [];
|
|
630
648
|
// subworkflow.run carries an inline base64 screenshot on some graph shapes;
|
|
631
649
|
// only used for the image block below.
|
|
@@ -729,13 +747,26 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
729
747
|
};
|
|
730
748
|
if (verdict.budget)
|
|
731
749
|
responsePayload.budget = verdict.budget;
|
|
750
|
+
// Bead w1os.1: say where the wall-clock went. setupMs is everything this MCP
|
|
751
|
+
// did before queuing the run (tunnel, route lock, health probe); runMs is
|
|
752
|
+
// queue-to-terminal on the backend (agent loop, incl. sign-in). Measured,
|
|
753
|
+
// not estimated.
|
|
754
|
+
if (execStartedAtMs !== undefined) {
|
|
755
|
+
responsePayload.timings = {
|
|
756
|
+
setupMs: execStartedAtMs - startTime,
|
|
757
|
+
runMs: runDoneAtMs - execStartedAtMs,
|
|
758
|
+
totalMs: Date.now() - startTime,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
732
761
|
// For "navigate and describe what you see", the answer IS the deliverable —
|
|
733
762
|
// and it reached callers only incidentally, buried in actionTrace[0].intent,
|
|
734
763
|
// which the verify-gate rewrites when it disagrees with the page. Surface it.
|
|
735
764
|
if (verdict.report)
|
|
736
765
|
responsePayload.report = verdict.report;
|
|
737
|
-
if (verdict.logins)
|
|
766
|
+
if (verdict.logins) {
|
|
738
767
|
responsePayload.logins = verdict.logins;
|
|
768
|
+
responsePayload.loginSummary = summarizeLogins(verdict.logins);
|
|
769
|
+
}
|
|
739
770
|
if (verdict.loginError)
|
|
740
771
|
responsePayload.loginError = verdict.loginError;
|
|
741
772
|
// Bead b5x6: the caller named an account and the run SUBMITTED an
|
|
@@ -801,7 +832,9 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
801
832
|
// browser_session block: presigned S3 URLs for HAR + console log + recording,
|
|
802
833
|
// passed through verbatim.
|
|
803
834
|
if (finalExecution.browserSession) {
|
|
804
|
-
|
|
835
|
+
// Bead w1os.3: artifacts are uploaded after the terminal state; re-read
|
|
836
|
+
// briefly while the backend says they are still queued.
|
|
837
|
+
responsePayload.browserSession = await refreshPendingArtifacts((id) => client.workflows.getExecution(id), executionUuid, finalExecution.browserSession) ?? finalExecution.browserSession;
|
|
805
838
|
}
|
|
806
839
|
logger.toolComplete('check_app_in_browser', duration);
|
|
807
840
|
// NOTE (bead 0bq): the final "Complete:" progress is emitted INSIDE
|
|
@@ -222,6 +222,84 @@ export const CADDY_HANDLER_ID = 'dbg-handler';
|
|
|
222
222
|
/** Placeholder upstream: dials a closed port so a stray request 502s cleanly
|
|
223
223
|
* from the first millisecond instead of exposing a raw connection-refused. */
|
|
224
224
|
const PLACEHOLDER_DIAL = '127.0.0.1:1';
|
|
225
|
+
/** Path prefix under which a secondary local origin is exposed on the tunnel. */
|
|
226
|
+
export const AUX_PATH_PREFIX = '/__o';
|
|
227
|
+
/** @id of the subroute holding the per-origin routes. */
|
|
228
|
+
export const CADDY_AUX_ID = 'dbg-aux';
|
|
229
|
+
const LOCAL_HOST_ALIASES = '(?:localhost|127\\.0\\.0\\.1|host\\.docker\\.internal|\\[::1\\])';
|
|
230
|
+
/**
|
|
231
|
+
* Location-header rewrite rules: a redirect to any local alias of a known port
|
|
232
|
+
* becomes a path on the tunnel origin, so the REMOTE browser (which cannot
|
|
233
|
+
* resolve `host.docker.internal` / dial its own `localhost:PORT`) follows it
|
|
234
|
+
* through the tunnel. The primary port maps to `/`, an aux port to `/__o/<port>/`.
|
|
235
|
+
*/
|
|
236
|
+
export function buildLocationRewrites(primaryPort, aux) {
|
|
237
|
+
const rules = [];
|
|
238
|
+
for (const o of aux) {
|
|
239
|
+
rules.push({
|
|
240
|
+
search_regexp: `^https?://${LOCAL_HOST_ALIASES}:${o.port}(?:/(.*))?$`,
|
|
241
|
+
replace: AUX_PATH_PREFIX + '/' + o.port + '/${1}',
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
if (primaryPort !== undefined) {
|
|
245
|
+
rules.push({
|
|
246
|
+
search_regexp: `^https?://${LOCAL_HOST_ALIASES}:${primaryPort}(?:/(.*))?$`,
|
|
247
|
+
// Once any aux origin exists the primary is addressed by prefix too, so a
|
|
248
|
+
// redirect BACK to it does not inherit an aux page's Referer-scoped routing.
|
|
249
|
+
replace: AUX_PATH_PREFIX + '/' + primaryPort + '/${1}',
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
return rules;
|
|
253
|
+
}
|
|
254
|
+
function withLocationRewrites(handler, rules) {
|
|
255
|
+
if (rules.length === 0)
|
|
256
|
+
return handler;
|
|
257
|
+
return { ...handler, headers: { response: { replace: { Location: rules } } } };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* The subroute that serves every authorised origin (aux origins plus the
|
|
261
|
+
* current primary), each addressable two ways:
|
|
262
|
+
* - `/__o/<port>/*` -> prefix stripped, proxied to that port;
|
|
263
|
+
* - any other path whose Referer is a `/__o/<port>/` page -> proxied to that
|
|
264
|
+
* port UNstripped, so root-relative links, form actions and assets on a
|
|
265
|
+
* secondary origin's page stay on that origin. The per-origin routes are
|
|
266
|
+
* fixed at config time, so a forged Referer can only select an origin the
|
|
267
|
+
* caller already authorised.
|
|
268
|
+
* A request matching neither falls through to the primary upstream.
|
|
269
|
+
*/
|
|
270
|
+
export function buildAuxSubroute(aux, primary, inDocker) {
|
|
271
|
+
const rules = buildLocationRewrites(primary?.port, aux);
|
|
272
|
+
const all = [...aux];
|
|
273
|
+
if (primary && aux.length > 0 && !all.some((o) => o.port === primary.port)) {
|
|
274
|
+
all.push({ port: primary.port, isHttpsLocal: primary.isHttpsLocal });
|
|
275
|
+
}
|
|
276
|
+
const proxyFor = (o) => {
|
|
277
|
+
const proxy = {
|
|
278
|
+
handler: 'reverse_proxy',
|
|
279
|
+
upstreams: [{ dial: resolveDialAddress(o.port, !!o.isHttpsLocal, inDocker) }],
|
|
280
|
+
};
|
|
281
|
+
if (o.isHttpsLocal)
|
|
282
|
+
proxy.transport = { protocol: 'http', tls: { insecure_skip_verify: true } };
|
|
283
|
+
return withLocationRewrites(proxy, rules);
|
|
284
|
+
};
|
|
285
|
+
const routes = [];
|
|
286
|
+
for (const o of all) {
|
|
287
|
+
routes.push({
|
|
288
|
+
match: [{ path: [`${AUX_PATH_PREFIX}/${o.port}/*`] }],
|
|
289
|
+
handle: [{ handler: 'rewrite', strip_path_prefix: `${AUX_PATH_PREFIX}/${o.port}` }, proxyFor(o)],
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
for (const o of all) {
|
|
293
|
+
routes.push({
|
|
294
|
+
match: [{
|
|
295
|
+
not: [{ path: [`${AUX_PATH_PREFIX}/*`] }],
|
|
296
|
+
header_regexp: { Referer: { pattern: `^https?://[^/]+${AUX_PATH_PREFIX}/${o.port}/` } },
|
|
297
|
+
}],
|
|
298
|
+
handle: [proxyFor(o)],
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
return { '@id': CADDY_AUX_ID, handler: 'subroute', routes };
|
|
302
|
+
}
|
|
225
303
|
/**
|
|
226
304
|
* Builds the eager-at-spawn Caddy JSON config. One code path for every call
|
|
227
305
|
* including the first (architecture doc §2.2): PATCH-only against a known
|
|
@@ -229,7 +307,7 @@ const PLACEHOLDER_DIAL = '127.0.0.1:1';
|
|
|
229
307
|
* building new structure. Both listeners bind 127.0.0.1 only — the admin
|
|
230
308
|
* API has no built-in auth, so this is a hard security requirement.
|
|
231
309
|
*/
|
|
232
|
-
export function buildCaddyConfig(proxyPort, adminPort) {
|
|
310
|
+
export function buildCaddyConfig(proxyPort, adminPort, aux = []) {
|
|
233
311
|
return {
|
|
234
312
|
admin: { listen: `127.0.0.1:${adminPort}` },
|
|
235
313
|
apps: {
|
|
@@ -238,6 +316,10 @@ export function buildCaddyConfig(proxyPort, adminPort) {
|
|
|
238
316
|
srv0: {
|
|
239
317
|
listen: [`127.0.0.1:${proxyPort}`],
|
|
240
318
|
routes: [
|
|
319
|
+
// Aux origins first so `/__o/<port>/*` never falls through to the primary upstream.
|
|
320
|
+
{
|
|
321
|
+
handle: [buildAuxSubroute(aux, undefined, isDockerEnv())],
|
|
322
|
+
},
|
|
241
323
|
{
|
|
242
324
|
handle: [
|
|
243
325
|
{
|
|
@@ -265,7 +347,7 @@ export function buildCaddyConfig(proxyPort, adminPort) {
|
|
|
265
347
|
* address, so an omitted `@id` deletes the tag and the NEXT PATCH 404s
|
|
266
348
|
* (only manifests on the second call — see LIFE-4 in the test suite).
|
|
267
349
|
*/
|
|
268
|
-
export function buildPatchBody(target, inDocker) {
|
|
350
|
+
export function buildPatchBody(target, inDocker, aux = []) {
|
|
269
351
|
const isHttpsLocal = !!target.isHttpsLocal;
|
|
270
352
|
const dial = resolveDialAddress(target.port, isHttpsLocal, inDocker);
|
|
271
353
|
const body = {
|
|
@@ -277,7 +359,8 @@ export function buildPatchBody(target, inDocker) {
|
|
|
277
359
|
// Local self-signed certs — doesn't touch the public leg's real TLS.
|
|
278
360
|
body.transport = { protocol: 'http', tls: { insecure_skip_verify: true } };
|
|
279
361
|
}
|
|
280
|
-
|
|
362
|
+
// Redirects to the primary origin or an authorised aux origin stay on the tunnel.
|
|
363
|
+
return aux.length > 0 ? withLocationRewrites(body, buildLocationRewrites(target.port, aux)) : body;
|
|
281
364
|
}
|
|
282
365
|
function adminHttpRequest(adminPort, method, reqPath, body, timeoutMs = 2000) {
|
|
283
366
|
return new Promise((resolve, reject) => {
|
|
@@ -325,6 +408,8 @@ export class CaddyProxyManager {
|
|
|
325
408
|
configPath = null;
|
|
326
409
|
lastProxyPort = null;
|
|
327
410
|
lastAppliedTarget = null;
|
|
411
|
+
/** Authorised secondary origins (bead w1os.2). Grow-only; survives a respawn. */
|
|
412
|
+
auxOrigins = [];
|
|
328
413
|
portChangedListeners = [];
|
|
329
414
|
constructor(opts = {}) {
|
|
330
415
|
// Short random hex suffix — PID alone collides once one process hosts
|
|
@@ -359,7 +444,7 @@ export class CaddyProxyManager {
|
|
|
359
444
|
this.lastAppliedTarget.isHttpsLocal === isHttpsLocal) {
|
|
360
445
|
return; // idempotent no-op — this is what makes same-port calls free
|
|
361
446
|
}
|
|
362
|
-
const body = buildPatchBody(target, isDockerEnv());
|
|
447
|
+
const body = buildPatchBody(target, isDockerEnv(), this.auxOrigins);
|
|
363
448
|
try {
|
|
364
449
|
await this.patchHandler(adminPort, body);
|
|
365
450
|
}
|
|
@@ -379,6 +464,33 @@ export class CaddyProxyManager {
|
|
|
379
464
|
await this.patchHandler(freshAdminPort, body);
|
|
380
465
|
}
|
|
381
466
|
this.lastAppliedTarget = { port: target.port, isHttpsLocal };
|
|
467
|
+
if (this.auxOrigins.length > 0)
|
|
468
|
+
await this.patchAux(this.lastAppliedTarget);
|
|
469
|
+
}
|
|
470
|
+
/** Re-issue the aux subroute so it knows the current primary (bead w1os.2). */
|
|
471
|
+
async patchAux(primary, origins = this.auxOrigins) {
|
|
472
|
+
const adminPort = this.adminPort;
|
|
473
|
+
if (adminPort === null)
|
|
474
|
+
return;
|
|
475
|
+
const res = await adminHttpRequest(adminPort, 'PATCH', `/id/${CADDY_AUX_ID}`, JSON.stringify(buildAuxSubroute(origins, primary, isDockerEnv())));
|
|
476
|
+
if (res.status < 200 || res.status >= 300) {
|
|
477
|
+
throw new CaddyAdminApiError(`PATCH /id/${CADDY_AUX_ID} failed: ${res.status} ${res.body}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
async addAuxOrigins(origins) {
|
|
481
|
+
const fresh = origins.filter((o) => !this.auxOrigins.some((a) => a.port === o.port && !!a.isHttpsLocal === !!o.isHttpsLocal));
|
|
482
|
+
if (fresh.length === 0)
|
|
483
|
+
return;
|
|
484
|
+
const { adminPort } = await this.ensureStarted();
|
|
485
|
+
const next = [...this.auxOrigins, ...fresh];
|
|
486
|
+
const primary = this.lastAppliedTarget;
|
|
487
|
+
// Replace the aux subroute, then re-issue the primary handler so its
|
|
488
|
+
// Location rewrites know the new origins. Both are idempotent PATCHes.
|
|
489
|
+
await this.patchAux(primary ?? undefined, next);
|
|
490
|
+
this.auxOrigins = next;
|
|
491
|
+
if (primary) {
|
|
492
|
+
await this.patchHandler(adminPort, buildPatchBody(primary, isDockerEnv(), next));
|
|
493
|
+
}
|
|
382
494
|
}
|
|
383
495
|
async isHealthy() {
|
|
384
496
|
if (!this.started || !this.adminPort || this.isChildDead)
|
|
@@ -445,7 +557,7 @@ export class CaddyProxyManager {
|
|
|
445
557
|
writeConfig(proxyPort, adminPort) {
|
|
446
558
|
fs.mkdirSync(this.configDir, { recursive: true });
|
|
447
559
|
const configPath = path.join(this.configDir, configFileName(process.pid, this.instanceId));
|
|
448
|
-
const config = buildCaddyConfig(proxyPort, adminPort);
|
|
560
|
+
const config = buildCaddyConfig(proxyPort, adminPort, this.auxOrigins);
|
|
449
561
|
fs.writeFileSync(configPath, JSON.stringify(config));
|
|
450
562
|
return configPath;
|
|
451
563
|
}
|
|
@@ -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
|
+
}
|
|
@@ -120,6 +120,23 @@ export function pollTimeoutReason(facts) {
|
|
|
120
120
|
const head = parts.join(' ');
|
|
121
121
|
return facts.lastStatus ? `${head}; last status: ${facts.lastStatus}.` : `${head}.`;
|
|
122
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Counts over the login records (bead w1os.4) — numbers only, so the MCP
|
|
125
|
+
* authors no text (epic 56kd). The backend appends a `no_login_form_detected`
|
|
126
|
+
* record (submitted:false, authenticated:false) after a successful sign-in
|
|
127
|
+
* whenever a later page had no form; read alone it looks like a failed login.
|
|
128
|
+
* The raw records stay verbatim (bead ymq2); this just states the tally:
|
|
129
|
+
* authenticated — records with authenticated:true
|
|
130
|
+
* failed — typed credentials (submitted:true) and did not authenticate
|
|
131
|
+
* notAttempted — nothing was typed (skipped or refused records)
|
|
132
|
+
*/
|
|
133
|
+
export function summarizeLogins(logins) {
|
|
134
|
+
if (!logins)
|
|
135
|
+
return undefined;
|
|
136
|
+
const authenticated = logins.filter((l) => l.authenticated === true).length;
|
|
137
|
+
const failed = logins.filter((l) => l.authenticated !== true && l.submitted === true).length;
|
|
138
|
+
return { total: logins.length, authenticated, failed, notAttempted: logins.length - authenticated - failed };
|
|
139
|
+
}
|
|
123
140
|
/** Credential sources that mean "the caller named this account for this run". */
|
|
124
141
|
const CALLER_SPECIFIED_SOURCES = new Set(['task', 'explicit', 'credential_id']);
|
|
125
142
|
/**
|
|
@@ -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,
|
|
@@ -106,6 +106,12 @@ export function buildTestPageChangesTool(ctx) {
|
|
|
106
106
|
type: "boolean",
|
|
107
107
|
description: "Default false. Set true to force a REAL login instead of reusing the warm session the backend keeps per account. Use when the login flow itself is what you're checking, when you suspect the stored session is stale, or when the app's only route between personas is a logout. Costs one login; the run re-captures afterwards, so later runs stay fast."
|
|
108
108
|
},
|
|
109
|
+
extraOrigins: {
|
|
110
|
+
type: "array",
|
|
111
|
+
items: { type: "string" },
|
|
112
|
+
description: "Extra LOCAL origins this run may be redirected to, e.g. [\"http://host.docker.internal:4717\", \"http://localhost:4304\"] (a bare port like \"4304\" also works). Use for flows that hop between local servers — an OAuth consent page on another port, a callback on a third — which the remote browser otherwise cannot reach (DNS_PROBE_FINISHED_NXDOMAIN / connection refused). Redirects to these origins, and to the main url's origin, are followed through the tunnel; only origins listed here are exposed. Hosts allowed: localhost, 127.0.0.1, [::1], host.docker.internal. Limitation: absolute URLs written inside page HTML (not redirects) are not rewritten.",
|
|
113
|
+
maxItems: 8
|
|
114
|
+
},
|
|
109
115
|
repoName: {
|
|
110
116
|
type: "string",
|
|
111
117
|
description: "GitHub repository name (e.g. 'my-org/my-repo'). Auto-detected from the current git repo — only provide this if you want to run against a different project than the one you're in."
|
package/dist/types/index.js
CHANGED
|
@@ -64,6 +64,11 @@ export const TestPageChangesInputSchema = z.object({
|
|
|
64
64
|
// skip login; that is right for speed and wrong when the login itself is what
|
|
65
65
|
// you are checking, or when the app's only route between personas is a logout.
|
|
66
66
|
freshSession: z.boolean().optional(),
|
|
67
|
+
// Extra LOCAL origins the run may be redirected to (bead w1os.2) — e.g. an OAuth
|
|
68
|
+
// vendor on :4717 (also host.docker.internal:4717) or a callback on :4304. Each is
|
|
69
|
+
// tunnelled and redirects to it are followed through the tunnel. Only origins named
|
|
70
|
+
// here are exposed.
|
|
71
|
+
extraOrigins: z.array(z.string().min(1)).max(8, 'extraOrigins accepts at most 8 origins.').optional(),
|
|
67
72
|
// Auth-precondition deep-link intent (bead 56kd.6) — "log in THEN go to X".
|
|
68
73
|
auth: AuthPreconditionSchema.optional(),
|
|
69
74
|
});
|
|
@@ -269,6 +274,16 @@ export const ProbePageInputSchema = z.object({
|
|
|
269
274
|
includeHtml: z.boolean().default(false),
|
|
270
275
|
captureScreenshots: z.boolean().default(true),
|
|
271
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(),
|
|
272
287
|
}).strict();
|
|
273
288
|
// ── E2E Suite Management ──────────────────────────────────────────────────────
|
|
274
289
|
const projectIdentifier = {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const PENDING = /queue|pending|processing|uploading|downloading/i;
|
|
2
|
+
export function artifactsPending(bs) {
|
|
3
|
+
if (!bs)
|
|
4
|
+
return false;
|
|
5
|
+
const statuses = [bs.recordingStatus, bs.harStatus, bs.consoleLogStatus];
|
|
6
|
+
return statuses.some((s) => typeof s === 'string' && PENDING.test(s));
|
|
7
|
+
}
|
|
8
|
+
function artifactsReady(bs) {
|
|
9
|
+
return !artifactsPending(bs) || (!!bs.harUrl && !!bs.consoleLogUrl);
|
|
10
|
+
}
|
|
11
|
+
export async function refreshPendingArtifacts(getExecution, executionUuid, current, opts = {}) {
|
|
12
|
+
const attempts = opts.attempts ?? 3;
|
|
13
|
+
const intervalMs = opts.intervalMs ?? 2000;
|
|
14
|
+
let latest = current;
|
|
15
|
+
for (let i = 0; i < attempts && artifactsPending(latest) && !(latest && artifactsReady(latest)); i++) {
|
|
16
|
+
if (opts.signal?.aborted)
|
|
17
|
+
break;
|
|
18
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
19
|
+
try {
|
|
20
|
+
const exec = await getExecution(executionUuid);
|
|
21
|
+
if (exec.browserSession)
|
|
22
|
+
latest = exec.browserSession;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
break; // artifact refresh is best-effort; never fail the run for it
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return latest;
|
|
29
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extra local origins a run may be redirected to (bead w1os.2). Kept out of
|
|
3
|
+
* tunnelContext.ts so handler tests that mock that module are unaffected.
|
|
4
|
+
*/
|
|
5
|
+
import { tunnelManager } from '../services/tunnel/tunnelManager.js';
|
|
6
|
+
import { extractLocalhostPort } from './urlParser.js';
|
|
7
|
+
const EXTRA_ORIGIN_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1', 'host.docker.internal']);
|
|
8
|
+
export class ExtraOriginError extends Error {
|
|
9
|
+
origin;
|
|
10
|
+
constructor(origin, reason) {
|
|
11
|
+
super(`extraOrigins entry "${origin}" ${reason}`);
|
|
12
|
+
this.origin = origin;
|
|
13
|
+
this.name = 'ExtraOriginError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Parse `4304`, `localhost:4304`, or `http(s)://host.docker.internal:4717[/…]`. */
|
|
17
|
+
export function parseExtraOrigin(raw) {
|
|
18
|
+
const t = raw.trim();
|
|
19
|
+
if (/^\d+$/.test(t)) {
|
|
20
|
+
const port = Number(t);
|
|
21
|
+
if (port < 1 || port > 65535)
|
|
22
|
+
throw new ExtraOriginError(raw, 'is not a valid port');
|
|
23
|
+
return { port };
|
|
24
|
+
}
|
|
25
|
+
let u;
|
|
26
|
+
try {
|
|
27
|
+
u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(t) ? t : `http://${t}`);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new ExtraOriginError(raw, 'is not a URL, host:port, or port');
|
|
31
|
+
}
|
|
32
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:')
|
|
33
|
+
throw new ExtraOriginError(raw, 'must be http or https');
|
|
34
|
+
if (!EXTRA_ORIGIN_HOSTS.has(u.hostname.toLowerCase())) {
|
|
35
|
+
throw new ExtraOriginError(raw, 'must be localhost, 127.0.0.1, [::1] or host.docker.internal');
|
|
36
|
+
}
|
|
37
|
+
const isHttpsLocal = u.protocol === 'https:';
|
|
38
|
+
const port = u.port ? Number(u.port) : isHttpsLocal ? 443 : 80;
|
|
39
|
+
return { port, ...(isHttpsLocal ? { isHttpsLocal: true } : {}) };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Authorise the caller's extra origins on this session's Caddy and record them
|
|
43
|
+
* on the context. Call after `acquirePortRoute`. No-op without a tunnel.
|
|
44
|
+
*/
|
|
45
|
+
export async function authorizeExtraOrigins(ctx, raw) {
|
|
46
|
+
if (!raw || raw.length === 0 || !ctx.isLocalhost || !ctx.tunnelId)
|
|
47
|
+
return ctx;
|
|
48
|
+
const primaryPort = extractLocalhostPort(ctx.originalUrl);
|
|
49
|
+
const origins = raw.map(parseExtraOrigin).filter((o) => o.port !== primaryPort);
|
|
50
|
+
if (origins.length === 0)
|
|
51
|
+
return ctx;
|
|
52
|
+
const info = tunnelManager.getTunnelInfo(ctx.tunnelId);
|
|
53
|
+
if (!info)
|
|
54
|
+
throw new Error(`authorizeExtraOrigins: no TunnelInfo for tunnel ${ctx.tunnelId}`);
|
|
55
|
+
await info.caddy.addAuxOrigins(origins);
|
|
56
|
+
return { ...ctx, extraOrigins: origins };
|
|
57
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { tunnelManager, getSessionKey } from '../services/tunnel/tunnelManager.js';
|
|
11
11
|
import { isLocalhostUrl, replaceTunnelUrls, retargetTunnelUrl, extractLocalhostPort } from './urlParser.js';
|
|
12
|
+
import { AUX_PATH_PREFIX } from '../services/caddy/caddyProxy.js';
|
|
12
13
|
// ─── URL resolution ──────────────────────────────────────────────────────────
|
|
13
14
|
/**
|
|
14
15
|
* Resolve tool input to a concrete URL string.
|
|
@@ -110,6 +111,12 @@ export function retargetAuxiliaryUrl(ctx, auxUrl) {
|
|
|
110
111
|
const port = extractLocalhostPort(auxUrl);
|
|
111
112
|
const primaryPort = extractLocalhostPort(ctx.originalUrl);
|
|
112
113
|
if (port !== undefined && primaryPort !== undefined && port !== primaryPort) {
|
|
114
|
+
// Bead w1os.2: a port the caller authorised via extraOrigins is reachable at /__o/<port>/.
|
|
115
|
+
if (ctx.extraOrigins?.some((o) => o.port === port)) {
|
|
116
|
+
const t = new URL(auxUrl);
|
|
117
|
+
const base = new URL(ctx.targetUrl);
|
|
118
|
+
return { ok: true, url: `${base.origin}${AUX_PATH_PREFIX}/${port}${t.pathname}${t.search}${t.hash}`, rewritten: true };
|
|
119
|
+
}
|
|
113
120
|
return { ok: false, reason: 'port_mismatch', port, primaryPort };
|
|
114
121
|
}
|
|
115
122
|
return { ok: true, url: retargetTunnelUrl(ctx.targetUrl, auxUrl), rewritten: true };
|
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
|
}
|