@debugg-ai/debugg-ai-mcp 6.1.6 → 6.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/handlers/localExecutorBridge.js +152 -0
- package/dist/handlers/testPageChangesHandler.js +37 -8
- package/dist/services/localBrowser.js +6 -65
- package/dist/services/localExecutor/credentialBox.js +43 -0
- package/dist/services/localExecutor/httpTransport.js +72 -0
- package/dist/services/localExecutor/opRunner.js +437 -0
- package/dist/services/localExecutor/ops.js +12 -0
- package/dist/services/localExecutor/session.js +125 -0
- package/dist/services/navigationGuard.js +71 -0
- package/dist/services/workflows.js +5 -0
- package/dist/tools/testPageChanges.js +8 -0
- package/dist/types/index.js +6 -0
- package/dist/utils/projectRequired.js +8 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [6.2.0] — 2026-11-13
|
|
11
|
+
|
|
12
|
+
> Local click-through needs a backend with local executors enabled (platform flag `EXECUTORS_LOCAL_ENABLED`) and `DEBUGGAI_LOCAL_BROWSER=1` on the MCP server. Without both, nothing changes for existing calls.
|
|
13
|
+
|
|
14
|
+
### Added — check_app_in_browser on your local Chrome, cloud brain (`browser: "local"`) (epic m7un)
|
|
15
|
+
- New `browser: "local"` (also `headless`, `useProfile`, `allowedDomains`) runs the check in Chrome on your machine while the cloud brain decides each step. The laptop dials out, so no tunnel is needed for localhost or private apps. Opt-in: set `DEBUGGAI_LOCAL_BROWSER=1` in the MCP server environment; stdio only (not the remote HTTP/OAuth server). The platform side is flag-gated, so it needs a backend that has local executors enabled.
|
|
16
|
+
- Uses the sense/think/act brain (`flow/tools/browse`; `DEBUGGAI_LOCAL_TEMPLATE` overrides the slug). The `browser_use` App Evaluation template cannot drive a local browser and is refused by the backend for local runs.
|
|
17
|
+
- Safety: every navigation hop (redirects included) is checked against an allowlist of the target host plus `allowedDomains`/`extraOrigins`; an empty allowlist is refused. Passwords you pass are never sent to the cloud: the brain sees `{{cred:label}}` and the MCP substitutes it locally. Stored environment credentials arrive sealed to a per-session public key and are opened only on your machine. No cookies, HAR or login state are uploaded.
|
|
18
|
+
- Screenshots go to the cloud by default (the brain needs them); `DEBUGGAI_LOCAL_SCREENSHOTS=local_only` keeps them on disk and sends a 1x1 placeholder. Full captures are written under `~/.debugg-ai/captures` (0600).
|
|
19
|
+
- `useProfile` uses an MCP-owned Chrome profile (`~/.debugg-ai/chrome-profile`), never your real Chrome, and requires an allowlist. Note: it keeps persistent cookies between runs but not session cookies (Chrome drops them on close), so an app whose login is a session cookie will ask you to sign in again on the next run.
|
|
20
|
+
- Limits are configuration from the server (heartbeat, lease, in-flight concurrency), not hard-coded in the client. Retries are off for local runs (a retry needs a fresh session). `LOG_LEVEL=debug` logs each executor command.
|
|
21
|
+
- Verified against a flag-on backend: a click-through, a credentialed multi-step login with task creation, and cancel mid-run (call returns at once, Chrome closed).
|
|
22
|
+
|
|
10
23
|
### Changed — test_suite `run` retired (platform-g3am)
|
|
11
24
|
- The server removed its suite-run route, so `run` only produced a raw HTML 404. It is gone from the tool's actions and description; a call from an older client now returns an `ActionRetired` error naming `results`, `check_app_in_browser` and `trigger_crawl`, without contacting the server.
|
|
12
25
|
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check_app_in_browser with browser:'local' (epic m7un). The cloud workflow runs as
|
|
3
|
+
* usual but its page calls are served by the Chrome on THIS machine through the
|
|
4
|
+
* Executor API v1 (sentinal docs/local-executor-api-design.md). This module owns the
|
|
5
|
+
* MCP side: create the session, hold Chrome, attach + serve commands while the
|
|
6
|
+
* workflow executes, tear down. The handler only calls prepare / begin / end.
|
|
7
|
+
*/
|
|
8
|
+
import { config } from '../config/index.js';
|
|
9
|
+
import { logger } from '../utils/logger.js';
|
|
10
|
+
import { localBrowserEnabled, LocalBrowserError, resolveAllowedDomains } from '../services/localBrowser.js';
|
|
11
|
+
import { OpRunner } from '../services/localExecutor/opRunner.js';
|
|
12
|
+
import { runExecutorSession } from '../services/localExecutor/session.js';
|
|
13
|
+
import { HttpExecutorTransport } from '../services/localExecutor/httpTransport.js';
|
|
14
|
+
import { generateSessionKeys } from '../services/localExecutor/credentialBox.js';
|
|
15
|
+
/**
|
|
16
|
+
* Passwords never go to the cloud for a local run: the workflow gets `{{cred:<label>}}`
|
|
17
|
+
* and the real value stays here. Environment-stored credentials (credentialId/role)
|
|
18
|
+
* live in the cloud and are not handled by this path yet.
|
|
19
|
+
*/
|
|
20
|
+
export function localizeCredentials(input) {
|
|
21
|
+
const secrets = {};
|
|
22
|
+
const ph = (label, password) => { secrets[label] = password; return `{{cred:${label}}}`; };
|
|
23
|
+
const out = { ...input };
|
|
24
|
+
if (input.username && input.password)
|
|
25
|
+
out.password = ph(input.username, input.password);
|
|
26
|
+
if (input.loginCredentials) {
|
|
27
|
+
out.loginCredentials = input.loginCredentials.map((c) => ({ ...c, password: ph(c.label ?? c.username, c.password) }));
|
|
28
|
+
}
|
|
29
|
+
if (input.auth?.password && input.auth.username) {
|
|
30
|
+
out.auth = { ...input.auth, password: ph(input.auth.username, input.auth.password) };
|
|
31
|
+
}
|
|
32
|
+
return { input: out, secrets };
|
|
33
|
+
}
|
|
34
|
+
export function localAllowedDomains(input, startUrl) {
|
|
35
|
+
const startHost = (() => { try {
|
|
36
|
+
return new URL(startUrl).hostname;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return '';
|
|
40
|
+
} })();
|
|
41
|
+
const extra = (input.extraOrigins ?? []).map((o) => { try {
|
|
42
|
+
return new URL(/^\d+$/.test(o) ? `http://localhost:${o}` : o).hostname;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return '';
|
|
46
|
+
} });
|
|
47
|
+
if (input.useProfile)
|
|
48
|
+
return resolveAllowedDomains(input.allowedDomains);
|
|
49
|
+
return [startHost, ...(input.allowedDomains ?? []), ...extra].filter(Boolean);
|
|
50
|
+
}
|
|
51
|
+
export async function prepareLocalRun(args) {
|
|
52
|
+
const { client, input, startUrl, secrets, signal } = args;
|
|
53
|
+
if (!localBrowserEnabled()) {
|
|
54
|
+
throw new LocalBrowserError('Local browser mode is off. Set DEBUGGAI_LOCAL_BROWSER=1 in the MCP server environment to allow it.', 'LOCAL_BROWSER_DISABLED');
|
|
55
|
+
}
|
|
56
|
+
const allowed = localAllowedDomains(input, startUrl);
|
|
57
|
+
if (allowed.length === 0) {
|
|
58
|
+
throw new LocalBrowserError('A profile session needs an allowlist of domains it may visit: pass allowedDomains or have the server owner set DEBUGGAI_PROFILE_ALLOWED_DOMAINS.', 'PROFILE_ALLOWLIST_REQUIRED');
|
|
59
|
+
}
|
|
60
|
+
const headless = input.headless !== false;
|
|
61
|
+
const screenshotsMode = process.env.DEBUGGAI_LOCAL_SCREENSHOTS === 'local_only' ? 'local_only' : 'cloud';
|
|
62
|
+
// Chrome starts while the session is being created and the workflow queued.
|
|
63
|
+
// Mutable on purpose: stored credentials are decrypted into it once the session exists.
|
|
64
|
+
const liveSecrets = { ...secrets };
|
|
65
|
+
const keys = generateSessionKeys();
|
|
66
|
+
const runnerP = OpRunner.open({ allowedDomains: allowed, headless, useProfile: !!input.useProfile, secrets: liveSecrets, screenshots: screenshotsMode, signal });
|
|
67
|
+
runnerP.catch(() => { });
|
|
68
|
+
let created;
|
|
69
|
+
try {
|
|
70
|
+
created = await client.tx.post('api/v1/executors/sessions', {
|
|
71
|
+
executor: 'local',
|
|
72
|
+
allowedDomains: allowed,
|
|
73
|
+
headless,
|
|
74
|
+
viewport: { w: 1280, h: 800 },
|
|
75
|
+
capture: { screenshots: screenshotsMode },
|
|
76
|
+
// Public half only. The private key never leaves this process.
|
|
77
|
+
executorPublicKey: keys.publicKey,
|
|
78
|
+
...(args.projectId ? { projectId: args.projectId } : {}),
|
|
79
|
+
...(input.environmentId ? { environmentId: input.environmentId } : {}),
|
|
80
|
+
...(input.credentialId ? { credentialId: input.credentialId } : {}),
|
|
81
|
+
...(input.credentialRole ? { credentialRole: input.credentialRole } : {}),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
(await runnerP.catch(() => null))?.close();
|
|
86
|
+
const status = err?.response?.status;
|
|
87
|
+
if (status === 404) {
|
|
88
|
+
throw new LocalBrowserError('The DebuggAI backend does not offer local browser execution yet (executor API not enabled for this account).', 'LOCAL_EXECUTOR_UNAVAILABLE');
|
|
89
|
+
}
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
const runner = await runnerP;
|
|
93
|
+
if (created.captureMode === 'cloud' || created.captureMode === 'local_only')
|
|
94
|
+
runner.setCaptureMode(created.captureMode);
|
|
95
|
+
if (typeof created.headless === 'boolean' && created.headless !== (headless !== false)) {
|
|
96
|
+
logger.warn(`Server runs this session with headless=${created.headless}; the local browser was already launched with headless=${headless !== false}.`);
|
|
97
|
+
}
|
|
98
|
+
for (const c of created.credentials ?? []) {
|
|
99
|
+
try {
|
|
100
|
+
const pw = keys.open(c.sealed, created.sessionId);
|
|
101
|
+
liveSecrets[c.label] = pw;
|
|
102
|
+
// The backend names the placeholder after the label OR the username depending on the resolution path; accept both.
|
|
103
|
+
if (c.username && !(c.username in liveSecrets))
|
|
104
|
+
liveSecrets[c.username] = pw;
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
logger.warn(`Could not open stored credential "${c.label}" (${err instanceof Error ? err.message : 'decrypt failed'})`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const limits = {
|
|
111
|
+
heartbeat_s: created.limits?.heartbeatS ?? 10,
|
|
112
|
+
lease_s: created.limits?.leaseS ?? 45,
|
|
113
|
+
max_inline_bytes: created.limits?.maxInlineBytes ?? 1_000_000,
|
|
114
|
+
max_batch: created.limits?.maxBatch ?? 1,
|
|
115
|
+
...(created.limits?.maxInflight ? { max_inflight: created.limits.maxInflight } : {}),
|
|
116
|
+
};
|
|
117
|
+
const transport = new HttpExecutorTransport({
|
|
118
|
+
baseUrl: config.api.baseUrl,
|
|
119
|
+
authorization: `${config.api.tokenType === 'bearer' ? 'Bearer' : 'Token'} ${config.api.key}`,
|
|
120
|
+
sessionId: created.sessionId,
|
|
121
|
+
secret: created.secret,
|
|
122
|
+
});
|
|
123
|
+
runner.setUploader((url, ct, bytes) => transport.upload(url, ct, bytes));
|
|
124
|
+
const stop = new AbortController();
|
|
125
|
+
const onAbort = () => stop.abort();
|
|
126
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
127
|
+
let running;
|
|
128
|
+
return {
|
|
129
|
+
sessionId: created.sessionId,
|
|
130
|
+
begin() {
|
|
131
|
+
running = runExecutorSession({
|
|
132
|
+
transport, runner, limits, signal: stop.signal,
|
|
133
|
+
attach: {
|
|
134
|
+
executor_version: 'debugg-ai-mcp',
|
|
135
|
+
ops_supported: OpRunner.supportedOps(),
|
|
136
|
+
browser: { name: 'chrome', headless },
|
|
137
|
+
profile: input.useProfile ? 'mcp_owned' : 'ephemeral',
|
|
138
|
+
credential_labels: Object.keys(liveSecrets),
|
|
139
|
+
},
|
|
140
|
+
}).catch((err) => { logger.warn(`local executor session failed: ${err instanceof Error ? err.message : err}`); return { ended: 'lease_lost', commandsRun: 0 }; });
|
|
141
|
+
},
|
|
142
|
+
async end() {
|
|
143
|
+
signal.removeEventListener('abort', onAbort);
|
|
144
|
+
if (!running) {
|
|
145
|
+
await runner.close();
|
|
146
|
+
return { ended: 'not_started', commandsRun: 0 };
|
|
147
|
+
}
|
|
148
|
+
stop.abort();
|
|
149
|
+
return running;
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -9,7 +9,7 @@ import { Logger } from '../utils/logger.js';
|
|
|
9
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
|
-
import { getEvalTemplateSlug } from '../services/workflows.js';
|
|
12
|
+
import { getEvalTemplateSlug, getLocalBrowseTemplateSlug } from '../services/workflows.js';
|
|
13
13
|
import { authorizeExtraOrigins, ExtraOriginError } from '../utils/extraOrigins.js';
|
|
14
14
|
import { refreshPendingArtifacts } from '../utils/artifactGrace.js';
|
|
15
15
|
import { adaptVerdict, credentialSubstitutions, summarizeLogins } from '../services/verdictAdapter.js';
|
|
@@ -23,6 +23,8 @@ import { extractLocalhostPort, rewriteLocalhostInText } from '../utils/urlParser
|
|
|
23
23
|
import { getCachedTemplateUuid, getCachedProjectUuid, invalidateTemplateCache, invalidateProjectCache, } from '../utils/handlerCaches.js';
|
|
24
24
|
import { isTransientWorkflowError, transientReasonTag } from '../utils/transientErrors.js';
|
|
25
25
|
import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
|
|
26
|
+
import { prepareLocalRun, localizeCredentials } from './localExecutorBridge.js';
|
|
27
|
+
import { LocalBrowserError } from '../services/localBrowser.js';
|
|
26
28
|
import { projectRequiredMessage, projectLookupFailedPayload } from '../utils/projectRequired.js';
|
|
27
29
|
const logger = new Logger({ module: 'testPageChangesHandler' });
|
|
28
30
|
// Bead kbxy: bounded retry on known transient backend signatures (Pydantic
|
|
@@ -136,6 +138,15 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
136
138
|
: undefined;
|
|
137
139
|
const client = new DebuggAIServerClient(config.api.key);
|
|
138
140
|
await client.init();
|
|
141
|
+
// browser:'local': Chrome on this machine does the clicking (no tunnel); passwords stay local.
|
|
142
|
+
const localMode = input.browser === 'local';
|
|
143
|
+
let localSecrets = {};
|
|
144
|
+
if (localMode) {
|
|
145
|
+
const l = localizeCredentials(input);
|
|
146
|
+
input = l.input;
|
|
147
|
+
localSecrets = l.secrets;
|
|
148
|
+
}
|
|
149
|
+
let localRun;
|
|
139
150
|
const originalUrl = resolveTargetUrl(input);
|
|
140
151
|
let ctx = buildContext(originalUrl);
|
|
141
152
|
let provision;
|
|
@@ -227,14 +238,14 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
227
238
|
// is a billed leak, and beats a backend node failing mid-run.
|
|
228
239
|
if (!projectUuid && !namedEnvironmentId && lookupError !== undefined && repoName) {
|
|
229
240
|
// The lookup FAILED — not "no project linked" (platform-y2ob.4).
|
|
230
|
-
const payload = projectLookupFailedPayload(repoName, lookupError);
|
|
241
|
+
const payload = projectLookupFailedPayload(repoName, lookupError, false);
|
|
231
242
|
logger.warn(`check_app_in_browser: ${payload.message}`);
|
|
232
243
|
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
233
244
|
}
|
|
234
245
|
if (!projectUuid && !namedEnvironmentId) {
|
|
235
246
|
const payload = {
|
|
236
247
|
error: 'ProjectRequired',
|
|
237
|
-
message: projectRequiredMessage(repoName),
|
|
248
|
+
message: projectRequiredMessage(repoName, false),
|
|
238
249
|
};
|
|
239
250
|
logger.warn(`check_app_in_browser: ${payload.message}`);
|
|
240
251
|
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
@@ -263,7 +274,10 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
263
274
|
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
264
275
|
}
|
|
265
276
|
}
|
|
266
|
-
if (
|
|
277
|
+
if (localMode) {
|
|
278
|
+
logger.info(`check_app_in_browser: local browser — no tunnel for ${ctx.originalUrl}`);
|
|
279
|
+
}
|
|
280
|
+
else if (config.devMode) {
|
|
267
281
|
// Dev mode: local backend can reach localhost directly — no tunnel needed.
|
|
268
282
|
logger.info(`check_app_in_browser: dev mode — using localhost URL directly: ${ctx.originalUrl}`);
|
|
269
283
|
}
|
|
@@ -375,11 +389,14 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
375
389
|
// Cache key = the dispatch slug so the cache key and the lookup can never
|
|
376
390
|
// drift apart (bug clka: the key used to be a decoupled 'app evaluation'
|
|
377
391
|
// literal while the lookup searched a different string).
|
|
378
|
-
const
|
|
379
|
-
|
|
392
|
+
const templateSlug = localMode ? getLocalBrowseTemplateSlug() : getEvalTemplateSlug();
|
|
393
|
+
const templateUuid = await getCachedTemplateUuid(templateSlug, async () => {
|
|
394
|
+
return localMode
|
|
395
|
+
? client.workflows.findTemplateBySlug(templateSlug)
|
|
396
|
+
: client.workflows.findEvaluationTemplate();
|
|
380
397
|
});
|
|
381
398
|
if (!templateUuid) {
|
|
382
|
-
throw new Error(
|
|
399
|
+
throw new Error(`${localMode ? 'Local browse' : 'App Evaluation'} workflow template not found (slug "${templateSlug}").`);
|
|
383
400
|
}
|
|
384
401
|
// --- Build context data (camelCase here — axiosTransport auto-converts to snake_case) ---
|
|
385
402
|
const contextData = {
|
|
@@ -395,6 +412,11 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
395
412
|
contextData.projectId = projectUuid;
|
|
396
413
|
}
|
|
397
414
|
contextData.headless = true; // D7: the MCP always runs headless — no opt-out.
|
|
415
|
+
if (localMode) {
|
|
416
|
+
localRun = await prepareLocalRun({ client, input, startUrl: originalUrl, secrets: localSecrets, signal: abortController.signal, projectId: projectUuid || undefined });
|
|
417
|
+
contextData.executorSessionId = localRun.sessionId;
|
|
418
|
+
contextData.goal = contextData.question; // flow/tools/browse reads `goal`
|
|
419
|
+
}
|
|
398
420
|
// Bead 56kd.6: forward the auth-precondition deep-link intent verbatim per
|
|
399
421
|
// backend contract sentinal-k8x1f.8 (contextData.auth). Thin relay — express
|
|
400
422
|
// intent + forward; the backend authenticates then navigates to deepUrl. Only
|
|
@@ -504,7 +526,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
504
526
|
// 28: Complete
|
|
505
527
|
const BACKEND_SETUP_END = 6;
|
|
506
528
|
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']);
|
|
507
|
-
const MAX_RETRIES = getMaxTransientRetries();
|
|
529
|
+
const MAX_RETRIES = localMode ? 0 : getMaxTransientRetries(); // a retry would need a fresh local session
|
|
508
530
|
let executeResponse;
|
|
509
531
|
let executionUuid = '';
|
|
510
532
|
let finalExecution;
|
|
@@ -539,6 +561,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
539
561
|
// the target forward; the previous attempt already reached a terminal
|
|
540
562
|
// state, so there is nothing to cancel there).
|
|
541
563
|
currentExecutionUuid = executionUuid;
|
|
564
|
+
localRun?.begin();
|
|
542
565
|
logger.info(`Execution queued: ${executionUuid}${attempt > 1 ? ` (retry ${attempt - 1}/${MAX_RETRIES})` : ''}`);
|
|
543
566
|
// The abort can fire BEFORE anything is queued — while provisioning the
|
|
544
567
|
// tunnel or resolving the template — and there is no abort check between
|
|
@@ -930,6 +953,11 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
930
953
|
return tunnelFault ? { content, isError: true } : { content };
|
|
931
954
|
}
|
|
932
955
|
catch (error) {
|
|
956
|
+
if (error instanceof LocalBrowserError) {
|
|
957
|
+
const payload = { error: error.code, message: error.message };
|
|
958
|
+
logger.warn(`check_app_in_browser (local): ${error.code}: ${error.message}`);
|
|
959
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
960
|
+
}
|
|
933
961
|
const duration = Date.now() - startTime;
|
|
934
962
|
logger.toolError('check_app_in_browser', error, duration);
|
|
935
963
|
if (error instanceof Error && (error.message.includes('not found') || error.message.includes('401'))) {
|
|
@@ -939,6 +967,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
939
967
|
throw handleExternalServiceError(error, 'DebuggAI', 'test execution');
|
|
940
968
|
}
|
|
941
969
|
finally {
|
|
970
|
+
await localRun?.end().catch(() => { });
|
|
942
971
|
if (requestSignal)
|
|
943
972
|
requestSignal.removeEventListener('abort', onAbort);
|
|
944
973
|
// §2.4: release this call's claim on the shared port route (no-op if we
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import fs from 'node:fs';
|
|
15
15
|
import os from 'node:os';
|
|
16
16
|
import path from 'node:path';
|
|
17
|
+
import { installNavigationGuard, isLoopbackHost } from './navigationGuard.js';
|
|
17
18
|
export class LocalBrowserError extends Error {
|
|
18
19
|
code;
|
|
19
20
|
constructor(message, code) {
|
|
@@ -70,18 +71,10 @@ async function loadPlaywright() {
|
|
|
70
71
|
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
|
}
|
|
72
73
|
}
|
|
73
|
-
function
|
|
74
|
-
try {
|
|
75
|
-
return new URL(u).hostname;
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
return u;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
function ensurePrivateDir(dir) {
|
|
74
|
+
export function ensurePrivateDir(dir) {
|
|
82
75
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
83
76
|
}
|
|
84
|
-
async function launch(opts) {
|
|
77
|
+
export async function launch(opts) {
|
|
85
78
|
const pw = await loadPlaywright();
|
|
86
79
|
const executablePath = findChrome();
|
|
87
80
|
const base = { headless: opts.headless, ...(executablePath ? { executablePath } : { channel: 'chrome' }) };
|
|
@@ -139,71 +132,19 @@ export async function localCapture(o) {
|
|
|
139
132
|
o.signal?.addEventListener('abort', onAbort, { once: true });
|
|
140
133
|
try {
|
|
141
134
|
const page = context.pages()[0] ?? await context.newPage();
|
|
135
|
+
const blocked = [];
|
|
142
136
|
if (o.useProfile) {
|
|
143
|
-
const isLocal = (h) => ['localhost', '127.0.0.1', '[::1]', '::1'].includes(h);
|
|
144
137
|
const startHost = (() => { try {
|
|
145
138
|
return new URL(o.url).hostname;
|
|
146
139
|
}
|
|
147
140
|
catch {
|
|
148
141
|
return '';
|
|
149
142
|
} })();
|
|
150
|
-
if (!hostAllowed(startHost, allowed) && !
|
|
143
|
+
if (!hostAllowed(startHost, allowed) && !isLoopbackHost(startHost)) {
|
|
151
144
|
throw new LocalBrowserError(`${startHost || o.url} is not in the allowlist for this profile session.`, 'DOMAIN_NOT_ALLOWED');
|
|
152
145
|
}
|
|
153
|
-
|
|
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(() => { }); });
|
|
146
|
+
await installNavigationGuard(context, allowed, (h) => blocked.push(h));
|
|
205
147
|
}
|
|
206
|
-
const blocked = [];
|
|
207
148
|
const consoleErrors = [];
|
|
208
149
|
const net = [];
|
|
209
150
|
page.on('console', (m) => { if (m.type() === 'error' || m.type() === 'warning')
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sealed-box credential delivery. The executor makes a fresh X25519 keypair per session
|
|
3
|
+
* and sends ONLY the public key at session creation. The server decrypts a stored
|
|
4
|
+
* credential with its own key (which never leaves the server), then re-encrypts it to
|
|
5
|
+
* this public key before it enters any queue or response. Only this process can open it.
|
|
6
|
+
*
|
|
7
|
+
* Envelope (JSON, all base64): { v:1, alg:'x25519-hkdf-sha256-aes256gcm', epk, iv, ct, tag }
|
|
8
|
+
* epk = sender's ephemeral X25519 public key, SPKI DER
|
|
9
|
+
* key = HKDF-SHA256(ikm = ECDH(epk, recipient private), salt = empty, info = 'debuggai-executor-credential-v1', 32 bytes)
|
|
10
|
+
* ct/tag = AES-256-GCM(key, iv(12 bytes), plaintext); AAD = the session id
|
|
11
|
+
*/
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
export const SEAL_ALG = 'x25519-hkdf-sha256-aes256gcm';
|
|
14
|
+
export const SEAL_INFO = 'debuggai-executor-credential-v1';
|
|
15
|
+
export function generateSessionKeys() {
|
|
16
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519');
|
|
17
|
+
return {
|
|
18
|
+
publicKey: publicKey.export({ type: 'spki', format: 'der' }).toString('base64'),
|
|
19
|
+
open(env, sessionId) {
|
|
20
|
+
if (env.v !== 1 || env.alg !== SEAL_ALG)
|
|
21
|
+
throw new Error(`Unsupported credential envelope (${env.alg}).`);
|
|
22
|
+
const epk = crypto.createPublicKey({ key: Buffer.from(env.epk, 'base64'), type: 'spki', format: 'der' });
|
|
23
|
+
const shared = crypto.diffieHellman({ privateKey, publicKey: epk });
|
|
24
|
+
const key = Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.alloc(0), SEAL_INFO, 32));
|
|
25
|
+
const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(env.iv, 'base64'));
|
|
26
|
+
d.setAAD(Buffer.from(sessionId));
|
|
27
|
+
d.setAuthTag(Buffer.from(env.tag, 'base64'));
|
|
28
|
+
return Buffer.concat([d.update(Buffer.from(env.ct, 'base64')), d.final()]).toString('utf8');
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Reference sealer (server side lives in the platform repo; used here for tests and as the spec). */
|
|
33
|
+
export function sealTo(publicKeyB64, plaintext, sessionId) {
|
|
34
|
+
const recipient = crypto.createPublicKey({ key: Buffer.from(publicKeyB64, 'base64'), type: 'spki', format: 'der' });
|
|
35
|
+
const eph = crypto.generateKeyPairSync('x25519');
|
|
36
|
+
const shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: recipient });
|
|
37
|
+
const key = Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.alloc(0), SEAL_INFO, 32));
|
|
38
|
+
const iv = crypto.randomBytes(12);
|
|
39
|
+
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
40
|
+
c.setAAD(Buffer.from(sessionId));
|
|
41
|
+
const ct = Buffer.concat([c.update(plaintext, 'utf8'), c.final()]);
|
|
42
|
+
return { v: 1, alg: SEAL_ALG, epk: eph.publicKey.export({ type: 'spki', format: 'der' }).toString('base64'), iv: iv.toString('base64'), ct: ct.toString('base64'), tag: c.getAuthTag().toString('base64') };
|
|
43
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { API_VERSION } from './ops.js';
|
|
2
|
+
export const EXECUTOR_PATHS = {
|
|
3
|
+
attach: (id) => `/api/v1/executors/sessions/${id}/attach`,
|
|
4
|
+
commands: (id, waitS) => `/api/v1/executors/sessions/${id}/commands?wait=${waitS}`,
|
|
5
|
+
results: (id) => `/api/v1/executors/sessions/${id}/results`,
|
|
6
|
+
heartbeat: (id) => `/api/v1/executors/sessions/${id}/heartbeat`,
|
|
7
|
+
cancel: (id) => `/api/v1/executors/sessions/${id}/cancel`,
|
|
8
|
+
};
|
|
9
|
+
export class HttpTransportError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
constructor(message, status) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.status = status;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class HttpExecutorTransport {
|
|
17
|
+
o;
|
|
18
|
+
constructor(o) {
|
|
19
|
+
this.o = o;
|
|
20
|
+
}
|
|
21
|
+
async req(method, path, body, signal) {
|
|
22
|
+
const f = this.o.fetchImpl ?? fetch;
|
|
23
|
+
const res = await f(this.o.baseUrl.replace(/\/$/, '') + path, {
|
|
24
|
+
method,
|
|
25
|
+
signal,
|
|
26
|
+
headers: {
|
|
27
|
+
authorization: this.o.authorization,
|
|
28
|
+
'x-executor-secret': this.o.secret,
|
|
29
|
+
'x-executor-api-version': String(API_VERSION),
|
|
30
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
31
|
+
},
|
|
32
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
33
|
+
});
|
|
34
|
+
if (res.status === 204)
|
|
35
|
+
return null;
|
|
36
|
+
if (!res.ok)
|
|
37
|
+
throw new HttpTransportError(`${method} ${path.split('?')[0]} -> HTTP ${res.status}`, res.status);
|
|
38
|
+
const text = await res.text();
|
|
39
|
+
return text ? JSON.parse(text) : null;
|
|
40
|
+
}
|
|
41
|
+
/** PUT raw bytes to a command's upload_url with the executor auth headers; returns the server's `ref`. */
|
|
42
|
+
async upload(url, contentType, bytes) {
|
|
43
|
+
const f = this.o.fetchImpl ?? fetch;
|
|
44
|
+
const res = await f(url, {
|
|
45
|
+
method: 'PUT',
|
|
46
|
+
headers: { authorization: this.o.authorization, 'x-executor-secret': this.o.secret, 'x-executor-api-version': String(API_VERSION), 'content-type': contentType },
|
|
47
|
+
body: bytes,
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok)
|
|
50
|
+
throw new HttpTransportError(`PUT upload -> HTTP ${res.status}`, res.status);
|
|
51
|
+
const text = await res.text();
|
|
52
|
+
let ref;
|
|
53
|
+
try {
|
|
54
|
+
ref = text ? JSON.parse(text).ref : undefined;
|
|
55
|
+
}
|
|
56
|
+
catch { /* not json */ }
|
|
57
|
+
return typeof ref === 'string' && ref ? ref : url.split('?')[0];
|
|
58
|
+
}
|
|
59
|
+
async attach(info) { await this.req('POST', EXECUTOR_PATHS.attach(this.o.sessionId), info); }
|
|
60
|
+
async poll(waitS, signal) {
|
|
61
|
+
const r = await this.req('GET', EXECUTOR_PATHS.commands(this.o.sessionId, waitS), undefined, signal);
|
|
62
|
+
if (Array.isArray(r))
|
|
63
|
+
return r;
|
|
64
|
+
const o = (r ?? {});
|
|
65
|
+
return { commands: o.commands ?? [], cancel: o.cancel, drain: o.drain };
|
|
66
|
+
}
|
|
67
|
+
async postResults(results) { await this.req('POST', EXECUTOR_PATHS.results(this.o.sessionId), { results }); }
|
|
68
|
+
async heartbeat() {
|
|
69
|
+
const r = (await this.req('POST', EXECUTOR_PATHS.heartbeat(this.o.sessionId), {}));
|
|
70
|
+
return { cancel: !!r?.cancel, drain: !!r?.drain };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpRunner: executes ONE Command in real local Chrome (playwright-core) and returns a
|
|
3
|
+
* CommandResult. Thin by design: Playwright's own semantics (auto-wait, actionability)
|
|
4
|
+
* do the work; this layer serialises, enforces the allowlist on every navigation hop,
|
|
5
|
+
* substitutes credential placeholders locally, redacts, and reports page events.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { launch, localBrowserEnabled, LocalBrowserError, hostAllowed, resolveAllowedDomains, debuggHome, ensurePrivateDir } from '../localBrowser.js';
|
|
10
|
+
import { installNavigationGuard, isLoopbackHost } from '../navigationGuard.js';
|
|
11
|
+
import { OPS } from './ops.js';
|
|
12
|
+
const SECRET_RE = /\{\{cred:([^}]+)\}\}/g;
|
|
13
|
+
const PX = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iqLiWAAAAABJRU5ErkJggg==';
|
|
14
|
+
class OpError extends Error {
|
|
15
|
+
code;
|
|
16
|
+
retryable;
|
|
17
|
+
constructor(code, message, retryable = false) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.retryable = retryable;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class OpRunner {
|
|
24
|
+
context;
|
|
25
|
+
closeBrowser;
|
|
26
|
+
opts;
|
|
27
|
+
events = [];
|
|
28
|
+
emit(e) { this.events.push({ ...e, ts: Date.now() }); }
|
|
29
|
+
pages = new Map();
|
|
30
|
+
ids = new Map();
|
|
31
|
+
nextPage = 1;
|
|
32
|
+
currentId = '';
|
|
33
|
+
closed = false;
|
|
34
|
+
blocked = [];
|
|
35
|
+
uploader;
|
|
36
|
+
/** Set once the session exists (auth headers are known only then). Without it, uploads fall back to an unauthenticated PUT. */
|
|
37
|
+
/** The server's effective capture mode wins over the local default (it is deployment config, not a per-request choice). */
|
|
38
|
+
setCaptureMode(mode) { this.opts.screenshots = mode; }
|
|
39
|
+
setUploader(fn) { this.uploader = fn; }
|
|
40
|
+
constructor(context, closeBrowser, opts) {
|
|
41
|
+
this.context = context;
|
|
42
|
+
this.closeBrowser = closeBrowser;
|
|
43
|
+
this.opts = opts;
|
|
44
|
+
}
|
|
45
|
+
static supportedOps() { return OPS; }
|
|
46
|
+
static async open(opts) {
|
|
47
|
+
if (!localBrowserEnabled()) {
|
|
48
|
+
throw new LocalBrowserError('Local browser mode is off. Set DEBUGGAI_LOCAL_BROWSER=1 in the MCP server environment to allow it.', 'LOCAL_BROWSER_DISABLED');
|
|
49
|
+
}
|
|
50
|
+
const allowed = opts.useProfile ? resolveAllowedDomains(opts.allowedDomains) : opts.allowedDomains;
|
|
51
|
+
if (allowed.length === 0) {
|
|
52
|
+
throw new LocalBrowserError('A local session needs a non-empty domain allowlist (allowedDomains, or DEBUGGAI_PROFILE_ALLOWED_DOMAINS for profile sessions).', 'ALLOWLIST_REQUIRED');
|
|
53
|
+
}
|
|
54
|
+
const { context, close } = await launch({ headless: opts.headless !== false, useProfile: !!opts.useProfile });
|
|
55
|
+
try {
|
|
56
|
+
const r = new OpRunner(context, close, { ...opts, allowedDomains: allowed });
|
|
57
|
+
const vp = opts.viewport ?? { width: 1280, height: 800 };
|
|
58
|
+
const first = context.pages()[0] ?? await context.newPage();
|
|
59
|
+
await first.setViewportSize(vp).catch(() => { });
|
|
60
|
+
await installNavigationGuard(context, allowed, (h) => r.blocked.push(h));
|
|
61
|
+
r.register(first);
|
|
62
|
+
context.on('page', (pg) => { const id = r.register(pg); r.emit({ type: 'popup', page_id: id, url: pg.url() }); });
|
|
63
|
+
opts.signal?.addEventListener('abort', () => { void r.close(); }, { once: true });
|
|
64
|
+
return r;
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
await close().catch(() => { });
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
register(pg) {
|
|
72
|
+
const id = `p${this.nextPage++}`;
|
|
73
|
+
this.pages.set(id, pg);
|
|
74
|
+
this.ids.set(pg, id);
|
|
75
|
+
this.currentId = id;
|
|
76
|
+
pg.on('console', (m) => { if (m.type() === 'error' || m.type() === 'warning')
|
|
77
|
+
this.emit({ type: 'console', level: m.type(), text: m.text().slice(0, 500) }); });
|
|
78
|
+
pg.on('pageerror', (e) => this.emit({ type: 'console', level: 'error', text: e.message.slice(0, 500) }));
|
|
79
|
+
pg.on('response', (r) => { if (r.status() >= 400)
|
|
80
|
+
this.emit({ type: 'request_failed', url: stripQuery(r.url()), status: r.status() }); });
|
|
81
|
+
pg.on('requestfailed', (r) => this.emit({ type: 'request_failed', url: stripQuery(r.url()), status: 'aborted' }));
|
|
82
|
+
pg.on('dialog', (d) => {
|
|
83
|
+
this.emit({ type: 'dialog', dialog: d.type(), message: d.message().slice(0, 300) });
|
|
84
|
+
void (d.type() === 'beforeunload' ? d.accept() : d.dismiss()).catch(() => { });
|
|
85
|
+
});
|
|
86
|
+
pg.on('download', (d) => this.emit({ type: 'download', url: stripQuery(d.url()), filename: d.suggestedFilename() }));
|
|
87
|
+
pg.on('close', () => { this.pages.delete(id); this.ids.delete(pg); if (this.currentId === id)
|
|
88
|
+
this.currentId = [...this.pages.keys()].pop() ?? ''; });
|
|
89
|
+
return id;
|
|
90
|
+
}
|
|
91
|
+
page(cmd) {
|
|
92
|
+
const id = cmd.target?.page_id || this.currentId;
|
|
93
|
+
const pg = this.pages.get(id);
|
|
94
|
+
if (!pg || pg.isClosed())
|
|
95
|
+
throw new OpError('invalid_args', `No open page${id ? ` with id ${id}` : ''}.`);
|
|
96
|
+
return pg;
|
|
97
|
+
}
|
|
98
|
+
locator(pg, cmd) {
|
|
99
|
+
const sel = cmd.target?.selector;
|
|
100
|
+
if (!sel)
|
|
101
|
+
throw new OpError('invalid_args', `${cmd.op} needs target.selector.`);
|
|
102
|
+
let scope = pg;
|
|
103
|
+
for (const f of cmd.target?.frame ?? [])
|
|
104
|
+
scope = scope.frameLocator(f);
|
|
105
|
+
return scope.locator(sel);
|
|
106
|
+
}
|
|
107
|
+
async execute(cmd) {
|
|
108
|
+
const t0 = Date.now();
|
|
109
|
+
this.blocked = [];
|
|
110
|
+
const base = { command_id: cmd.id };
|
|
111
|
+
if (this.closed)
|
|
112
|
+
return { ...base, ok: false, duration_ms: 0, error: { code: 'cancelled', message: 'executor is closed', retryable: false } };
|
|
113
|
+
if (!OPS.includes(cmd.op)) {
|
|
114
|
+
return { ...base, ok: false, duration_ms: 0, error: { code: 'unsupported_op', message: `Unsupported op: ${cmd.op}`, retryable: false, detail: { op: cmd.op } } };
|
|
115
|
+
}
|
|
116
|
+
let observation;
|
|
117
|
+
let error;
|
|
118
|
+
let abortTimer;
|
|
119
|
+
try {
|
|
120
|
+
const work = this.run(cmd);
|
|
121
|
+
const guarded = cmd.deadline_ms
|
|
122
|
+
? Promise.race([work, new Promise((_r, rej) => { abortTimer = setTimeout(() => rej(new OpError('timeout', `Command exceeded deadline_ms=${cmd.deadline_ms}.`)), cmd.deadline_ms); })])
|
|
123
|
+
: work;
|
|
124
|
+
observation = await guarded;
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
error = await this.toError(err, cmd);
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
if (abortTimer)
|
|
131
|
+
clearTimeout(abortTimer);
|
|
132
|
+
}
|
|
133
|
+
const pg = this.pages.get(cmd.target?.page_id || this.currentId);
|
|
134
|
+
const out = {
|
|
135
|
+
...base, ok: !error, duration_ms: Date.now() - t0,
|
|
136
|
+
url: pg && !pg.isClosed() ? pg.url() : undefined,
|
|
137
|
+
title: pg && !pg.isClosed() ? await pg.title().catch(() => undefined) : undefined,
|
|
138
|
+
events: this.events.splice(0).slice(0, 100),
|
|
139
|
+
...(observation ? { observation } : {}),
|
|
140
|
+
...(error ? { error } : {}),
|
|
141
|
+
};
|
|
142
|
+
return this.redactSecrets(out);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Nothing the cloud receives may contain a live secret. The page itself can echo one back (the brain's
|
|
146
|
+
* snapshot script reads every input's value, so a filled password field returns the real password), so
|
|
147
|
+
* scrub every string in the result, replacing the value with its placeholder. Screenshot pixels are
|
|
148
|
+
* left alone (password inputs are masked by the browser).
|
|
149
|
+
*/
|
|
150
|
+
redactSecrets(result) {
|
|
151
|
+
const pairs = Object.entries(this.opts.secrets ?? {}).filter(([, v]) => typeof v === 'string' && v.length > 0).sort((a, b) => b[1].length - a[1].length);
|
|
152
|
+
if (pairs.length === 0)
|
|
153
|
+
return result;
|
|
154
|
+
const walk = (v, key) => {
|
|
155
|
+
if (typeof v === 'string') {
|
|
156
|
+
if (key === 'base64')
|
|
157
|
+
return v;
|
|
158
|
+
let o = v;
|
|
159
|
+
for (const [label, val] of pairs)
|
|
160
|
+
if (o.includes(val))
|
|
161
|
+
o = o.split(val).join(`{{cred:${label}}}`);
|
|
162
|
+
return o;
|
|
163
|
+
}
|
|
164
|
+
if (Array.isArray(v))
|
|
165
|
+
return v.map((x) => walk(x));
|
|
166
|
+
if (v && typeof v === 'object')
|
|
167
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x, k)]));
|
|
168
|
+
return v;
|
|
169
|
+
};
|
|
170
|
+
return walk(result);
|
|
171
|
+
}
|
|
172
|
+
sub(v) {
|
|
173
|
+
const s = typeof v === 'string' ? v : String(v ?? '');
|
|
174
|
+
return s.replace(SECRET_RE, (_m, label) => {
|
|
175
|
+
const val = this.opts.secrets?.[label];
|
|
176
|
+
if (val === undefined)
|
|
177
|
+
throw new OpError('invalid_args', `Unknown credential placeholder {{cred:${label}}}.`);
|
|
178
|
+
return val;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
to(cmd) {
|
|
182
|
+
const t = cmd.args?.timeout_ms;
|
|
183
|
+
return typeof t === 'number' ? t : this.opts.defaultTimeoutMs;
|
|
184
|
+
}
|
|
185
|
+
async run(cmd) {
|
|
186
|
+
const a = cmd.args ?? {};
|
|
187
|
+
const timeout = this.to(cmd);
|
|
188
|
+
const act = { kind: 'action' };
|
|
189
|
+
switch (cmd.op) {
|
|
190
|
+
case 'goto': {
|
|
191
|
+
const pg = this.page(cmd);
|
|
192
|
+
const url = String(a.url ?? '');
|
|
193
|
+
let host;
|
|
194
|
+
try {
|
|
195
|
+
host = new URL(url).hostname;
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
throw new OpError('invalid_args', `Invalid URL: ${url}`);
|
|
199
|
+
}
|
|
200
|
+
if (!hostAllowed(host, this.opts.allowedDomains) && !isLoopbackHost(host))
|
|
201
|
+
throw new OpError('domain_blocked', `${host} is not in this session's allowlist (${this.opts.allowedDomains.join(', ')}).`, false);
|
|
202
|
+
const resp = await pg.goto(url, { waitUntil: a.wait_until ?? 'domcontentloaded', timeout });
|
|
203
|
+
return { kind: 'action', value: { status: resp?.status() ?? null } };
|
|
204
|
+
}
|
|
205
|
+
case 'back':
|
|
206
|
+
await this.page(cmd).goBack({ waitUntil: 'domcontentloaded', timeout });
|
|
207
|
+
return act;
|
|
208
|
+
case 'forward':
|
|
209
|
+
await this.page(cmd).goForward({ waitUntil: 'domcontentloaded', timeout });
|
|
210
|
+
return act;
|
|
211
|
+
case 'reload':
|
|
212
|
+
await this.page(cmd).reload({ waitUntil: 'domcontentloaded', timeout });
|
|
213
|
+
return act;
|
|
214
|
+
case 'evaluate': {
|
|
215
|
+
const pg = this.page(cmd);
|
|
216
|
+
const expr = String(a.expression ?? '');
|
|
217
|
+
// Playwright does not invoke a function passed as a string, so run the source ourselves: a
|
|
218
|
+
// function source is called with (element?, arg); any other expression is returned as its value.
|
|
219
|
+
const value = cmd.target?.selector
|
|
220
|
+
? await this.locator(pg, cmd).first().evaluate((el, [src, arg]) => { const f = (0, eval)('(' + src + ')'); return typeof f === 'function' ? f(el, arg) : f; }, [expr, a.arg], { timeout })
|
|
221
|
+
: await pg.evaluate(([src, arg]) => { const f = (0, eval)('(' + src + ')'); return typeof f === 'function' ? f(arg) : f; }, [expr, a.arg]);
|
|
222
|
+
return { kind: 'eval', value: value === undefined ? null : value };
|
|
223
|
+
}
|
|
224
|
+
case 'click': {
|
|
225
|
+
const loc = this.locator(this.page(cmd), cmd).first();
|
|
226
|
+
const o = { timeout, button: a.button, force: a.force };
|
|
227
|
+
if (a.double)
|
|
228
|
+
await loc.dblclick(o);
|
|
229
|
+
else
|
|
230
|
+
await loc.click(o);
|
|
231
|
+
return act;
|
|
232
|
+
}
|
|
233
|
+
case 'dblclick':
|
|
234
|
+
await this.locator(this.page(cmd), cmd).first().dblclick({ timeout, force: a.force });
|
|
235
|
+
return act;
|
|
236
|
+
case 'fill':
|
|
237
|
+
await this.locator(this.page(cmd), cmd).first().fill(this.sub(a.value ?? a.text), { timeout });
|
|
238
|
+
return act;
|
|
239
|
+
case 'type':
|
|
240
|
+
await this.locator(this.page(cmd), cmd).first().pressSequentially(this.sub(a.text ?? a.value), { timeout, delay: (a.delay_ms ?? a.delay) });
|
|
241
|
+
return act;
|
|
242
|
+
case 'press': {
|
|
243
|
+
const key = this.sub(a.key);
|
|
244
|
+
if (cmd.target?.selector)
|
|
245
|
+
await this.locator(this.page(cmd), cmd).first().press(key, { timeout });
|
|
246
|
+
else
|
|
247
|
+
await this.page(cmd).keyboard.press(key);
|
|
248
|
+
return act;
|
|
249
|
+
}
|
|
250
|
+
case 'hover':
|
|
251
|
+
await this.locator(this.page(cmd), cmd).first().hover({ timeout });
|
|
252
|
+
return act;
|
|
253
|
+
case 'select_option': {
|
|
254
|
+
const v = a.index !== undefined ? { index: Number(a.index) } : a.label !== undefined ? { label: String(a.label) } : (a.values ?? a.value);
|
|
255
|
+
await this.locator(this.page(cmd), cmd).first().selectOption(v, { timeout });
|
|
256
|
+
return act;
|
|
257
|
+
}
|
|
258
|
+
case 'check':
|
|
259
|
+
await this.locator(this.page(cmd), cmd).first().check({ timeout });
|
|
260
|
+
return act;
|
|
261
|
+
case 'uncheck':
|
|
262
|
+
await this.locator(this.page(cmd), cmd).first().uncheck({ timeout });
|
|
263
|
+
return act;
|
|
264
|
+
case 'focus':
|
|
265
|
+
await this.locator(this.page(cmd), cmd).first().focus({ timeout });
|
|
266
|
+
return act;
|
|
267
|
+
case 'scroll': {
|
|
268
|
+
const pg = this.page(cmd);
|
|
269
|
+
const to = a.to_selector ?? cmd.target?.selector;
|
|
270
|
+
if (to)
|
|
271
|
+
await (a.to_selector ? pg.locator(to) : this.locator(pg, cmd)).first().scrollIntoViewIfNeeded({ timeout });
|
|
272
|
+
else
|
|
273
|
+
await pg.mouse.wheel(Number(a.dx ?? a.x ?? 0), Number(a.dy ?? a.y ?? 600));
|
|
274
|
+
return act;
|
|
275
|
+
}
|
|
276
|
+
case 'wait_for_selector':
|
|
277
|
+
await this.locator(this.page(cmd), cmd).first().waitFor({ state: a.state ?? 'visible', timeout });
|
|
278
|
+
return act;
|
|
279
|
+
case 'wait_for_load_state':
|
|
280
|
+
await this.page(cmd).waitForLoadState(a.state ?? 'load', { timeout });
|
|
281
|
+
return act;
|
|
282
|
+
case 'wait_for_function':
|
|
283
|
+
await this.page(cmd).waitForFunction(String(a.expression ?? ''), a.arg, { timeout });
|
|
284
|
+
return act;
|
|
285
|
+
case 'query': return { kind: 'query', value: await this.query(this.locator(this.page(cmd), cmd).first(), String(a.what ?? a.kind ?? 'text'), a, timeout) };
|
|
286
|
+
case 'query_all': {
|
|
287
|
+
const loc = this.locator(this.page(cmd), cmd);
|
|
288
|
+
const n = Math.min(await loc.count(), Number(a.limit ?? 500));
|
|
289
|
+
const out = [];
|
|
290
|
+
for (let i = 0; i < n; i++)
|
|
291
|
+
out.push(await this.query(loc.nth(i), String(a.what ?? a.kind ?? 'text'), a, timeout));
|
|
292
|
+
return { kind: 'query', value: out };
|
|
293
|
+
}
|
|
294
|
+
case 'screenshot': return this.screenshot(cmd);
|
|
295
|
+
case 'content': return { kind: 'content', value: await this.page(cmd).content() };
|
|
296
|
+
case 'set_content':
|
|
297
|
+
await this.page(cmd).setContent(String(a.html ?? ''), { timeout });
|
|
298
|
+
return act;
|
|
299
|
+
case 'add_init_script':
|
|
300
|
+
await this.context.addInitScript(String(a.script ?? ''));
|
|
301
|
+
return act;
|
|
302
|
+
case 'title': return { kind: 'query', value: await this.page(cmd).title() };
|
|
303
|
+
case 'url': return { kind: 'query', value: this.page(cmd).url() };
|
|
304
|
+
case 'set_viewport':
|
|
305
|
+
await this.page(cmd).setViewportSize({ width: Number(a.w ?? a.width ?? 1280), height: Number(a.h ?? a.height ?? 800) });
|
|
306
|
+
return act;
|
|
307
|
+
case 'new_page': {
|
|
308
|
+
const pg = await this.context.newPage();
|
|
309
|
+
if (a.url) {
|
|
310
|
+
let h;
|
|
311
|
+
try {
|
|
312
|
+
h = new URL(String(a.url)).hostname;
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
throw new OpError('invalid_args', `Invalid URL: ${a.url}`);
|
|
316
|
+
}
|
|
317
|
+
if (!hostAllowed(h, this.opts.allowedDomains) && !isLoopbackHost(h)) {
|
|
318
|
+
await pg.close();
|
|
319
|
+
throw new OpError('domain_blocked', `${h} is not in this session's allowlist.`);
|
|
320
|
+
}
|
|
321
|
+
await pg.goto(String(a.url), { waitUntil: 'domcontentloaded', timeout });
|
|
322
|
+
}
|
|
323
|
+
const id = this.ids.get(pg) ?? this.register(pg);
|
|
324
|
+
return { kind: 'action', value: { page_id: id } };
|
|
325
|
+
}
|
|
326
|
+
case 'list_pages': return { kind: 'query', value: [...this.pages.entries()].map(([id, p]) => ({ page_id: id, url: p.url(), current: id === this.currentId })) };
|
|
327
|
+
case 'switch_page': {
|
|
328
|
+
const id = String(a.page_id ?? cmd.target?.page_id ?? '');
|
|
329
|
+
if (!this.pages.has(id))
|
|
330
|
+
throw new OpError('invalid_args', `No page ${id}.`);
|
|
331
|
+
this.currentId = id;
|
|
332
|
+
await this.pages.get(id).bringToFront().catch(() => { });
|
|
333
|
+
return act;
|
|
334
|
+
}
|
|
335
|
+
case 'close_page':
|
|
336
|
+
await this.page(cmd).close();
|
|
337
|
+
return act;
|
|
338
|
+
default: throw new OpError('unsupported_op', `Unsupported op: ${cmd.op}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async query(loc, kind, a, timeout) {
|
|
342
|
+
switch (kind) {
|
|
343
|
+
case 'count': return loc.count();
|
|
344
|
+
case 'text': return loc.innerText({ timeout });
|
|
345
|
+
case 'html': return loc.innerHTML({ timeout });
|
|
346
|
+
case 'attr': return loc.getAttribute(String(a.name ?? ''), { timeout });
|
|
347
|
+
case 'value': {
|
|
348
|
+
const type = (await loc.getAttribute('type', { timeout }).catch(() => null))?.toLowerCase();
|
|
349
|
+
return type === 'password' ? '' : loc.inputValue({ timeout });
|
|
350
|
+
}
|
|
351
|
+
case 'visible': return loc.isVisible();
|
|
352
|
+
case 'enabled': return loc.isEnabled({ timeout });
|
|
353
|
+
case 'checked': return loc.isChecked({ timeout });
|
|
354
|
+
case 'box': return loc.boundingBox({ timeout });
|
|
355
|
+
default: throw new OpError('invalid_args', `Unknown query kind: ${kind}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
async screenshot(cmd) {
|
|
359
|
+
const a = cmd.args ?? {};
|
|
360
|
+
const pg = this.page(cmd);
|
|
361
|
+
const shotType = a.type === 'jpeg' || cmd.upload_content_type === 'image/jpeg' ? 'jpeg' : 'png';
|
|
362
|
+
const quality = Number(a.quality ?? 70);
|
|
363
|
+
const buf = cmd.target?.selector
|
|
364
|
+
? await this.locator(pg, cmd).first().screenshot({ type: shotType, ...(shotType === 'jpeg' ? { quality } : {}), timeout: this.to(cmd) })
|
|
365
|
+
: await pg.screenshot({ type: shotType, ...(shotType === 'jpeg' ? { quality } : {}), fullPage: !!a.full_page, timeout: this.to(cmd) });
|
|
366
|
+
const dir = path.join(debuggHome(), 'captures');
|
|
367
|
+
ensurePrivateDir(dir);
|
|
368
|
+
const file = path.join(dir, `shot-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.${shotType === 'jpeg' ? 'jpg' : 'png'}`);
|
|
369
|
+
fs.writeFileSync(file, buf, { mode: 0o600 });
|
|
370
|
+
if (this.opts.screenshots === 'local_only') {
|
|
371
|
+
return { kind: 'screenshot', value: { base64: PX, local_path: file, redacted: true }, screenshot_ref: null };
|
|
372
|
+
}
|
|
373
|
+
if (cmd.upload_url) {
|
|
374
|
+
const ct = cmd.upload_content_type ?? (shotType === 'jpeg' ? 'image/jpeg' : 'image/png');
|
|
375
|
+
let ref;
|
|
376
|
+
if (this.uploader) {
|
|
377
|
+
try {
|
|
378
|
+
ref = await this.uploader(cmd.upload_url, ct, new Uint8Array(buf));
|
|
379
|
+
}
|
|
380
|
+
catch (e) {
|
|
381
|
+
throw new OpError('internal', `Screenshot upload failed: ${e instanceof Error ? e.message : String(e)}`, true);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
const res = await fetch(cmd.upload_url, { method: 'PUT', headers: { 'content-type': ct }, body: new Uint8Array(buf) });
|
|
386
|
+
if (!res.ok)
|
|
387
|
+
throw new OpError('internal', `Screenshot upload failed: HTTP ${res.status}`, true);
|
|
388
|
+
ref = cmd.upload_url.split('?')[0];
|
|
389
|
+
}
|
|
390
|
+
return { kind: 'screenshot', value: { local_path: file }, screenshot_ref: ref };
|
|
391
|
+
}
|
|
392
|
+
if (buf.length > (this.opts.maxInlineBytes ?? 1_000_000)) {
|
|
393
|
+
throw new OpError('invalid_args', `Screenshot is ${buf.length} bytes, over max_inline_bytes, and no upload_url was given.`);
|
|
394
|
+
}
|
|
395
|
+
return { kind: 'screenshot', value: { base64: buf.toString('base64'), local_path: file }, screenshot_ref: null };
|
|
396
|
+
}
|
|
397
|
+
async toError(err, cmd) {
|
|
398
|
+
if (err instanceof OpError)
|
|
399
|
+
return { code: err.code, message: err.message, retryable: err.retryable };
|
|
400
|
+
const msg = err instanceof Error ? err.message.split('\n')[0] : String(err);
|
|
401
|
+
if (this.blocked.length > 0)
|
|
402
|
+
return { code: 'domain_blocked', message: `Navigation to ${[...new Set(this.blocked)].join(', ')} was blocked by the allowlist.`, retryable: false };
|
|
403
|
+
if (/Target (page, context or browser )?has been closed|browser has been closed|Page crashed/i.test(msg))
|
|
404
|
+
return { code: 'browser_crashed', message: msg, retryable: false };
|
|
405
|
+
if (/net::ERR|NS_ERROR|Navigation failed|ERR_/i.test(msg))
|
|
406
|
+
return { code: 'navigation_failed', message: msg, retryable: true };
|
|
407
|
+
if (/not visible|not attached|hidden|not stable|intercepts pointer/i.test(msg))
|
|
408
|
+
return { code: 'not_visible', message: msg, retryable: true };
|
|
409
|
+
if (/strict mode violation|waiting for locator|No node found/i.test(msg) || err instanceof Error && err.name === 'TimeoutError') {
|
|
410
|
+
if (cmd.target?.selector) {
|
|
411
|
+
try {
|
|
412
|
+
const n = await this.locator(this.page(cmd), cmd).count();
|
|
413
|
+
if (n === 0)
|
|
414
|
+
return { code: 'target_not_found', message: msg, retryable: true };
|
|
415
|
+
}
|
|
416
|
+
catch { /* fall through */ }
|
|
417
|
+
}
|
|
418
|
+
return { code: 'timeout', message: msg, retryable: true };
|
|
419
|
+
}
|
|
420
|
+
return { code: 'internal', message: msg, retryable: false };
|
|
421
|
+
}
|
|
422
|
+
async close() {
|
|
423
|
+
if (this.closed)
|
|
424
|
+
return;
|
|
425
|
+
this.closed = true;
|
|
426
|
+
await this.closeBrowser().catch(() => { });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
function stripQuery(u) {
|
|
430
|
+
try {
|
|
431
|
+
const x = new URL(u);
|
|
432
|
+
return x.origin + x.pathname;
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
return u.slice(0, 200);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types for the Browser Executor API v1 (sentinal docs/local-executor-api-design.md).
|
|
3
|
+
* The cloud brain's page calls arrive as Commands; this side answers with Results.
|
|
4
|
+
*/
|
|
5
|
+
export const API_VERSION = 1;
|
|
6
|
+
export const OPS = [
|
|
7
|
+
'goto', 'back', 'forward', 'reload', 'evaluate',
|
|
8
|
+
'click', 'dblclick', 'fill', 'type', 'press', 'hover', 'select_option', 'check', 'uncheck', 'focus', 'scroll',
|
|
9
|
+
'wait_for_selector', 'wait_for_load_state', 'wait_for_function',
|
|
10
|
+
'query', 'query_all', 'screenshot', 'content', 'set_content', 'add_init_script', 'title', 'url', 'set_viewport',
|
|
11
|
+
'new_page', 'list_pages', 'switch_page', 'close_page',
|
|
12
|
+
];
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { logger } from '../../utils/logger.js';
|
|
2
|
+
export async function runExecutorSession(o) {
|
|
3
|
+
const { transport, runner, limits } = o;
|
|
4
|
+
const stop = new AbortController();
|
|
5
|
+
let ended = null;
|
|
6
|
+
const finish = (why) => { if (!ended) {
|
|
7
|
+
ended = why;
|
|
8
|
+
stop.abort();
|
|
9
|
+
} };
|
|
10
|
+
o.signal?.addEventListener('abort', () => finish('aborted'), { once: true });
|
|
11
|
+
if (o.signal?.aborted)
|
|
12
|
+
finish('aborted');
|
|
13
|
+
await transport.attach(o.attach);
|
|
14
|
+
let lastContact = Date.now();
|
|
15
|
+
const retryDelay = o.retryDelayMs ?? 1000;
|
|
16
|
+
const hb = setInterval(() => {
|
|
17
|
+
transport.heartbeat().then((r) => {
|
|
18
|
+
lastContact = Date.now();
|
|
19
|
+
if (r.cancel)
|
|
20
|
+
finish('cancelled');
|
|
21
|
+
else if (r.drain)
|
|
22
|
+
finish('drained');
|
|
23
|
+
}).catch(() => { if (Date.now() - lastContact > limits.lease_s * 1000)
|
|
24
|
+
finish('lease_lost'); });
|
|
25
|
+
}, Math.max(limits.heartbeat_s * 1000, 50));
|
|
26
|
+
const done = new Map(); // idempotent replays
|
|
27
|
+
const inflight = new Set(); // the cloud re-delivers unfinished commands; never run one twice
|
|
28
|
+
let commandsRun = 0;
|
|
29
|
+
try {
|
|
30
|
+
while (!ended) {
|
|
31
|
+
let cmds;
|
|
32
|
+
try {
|
|
33
|
+
const polled = await transport.poll(o.pollWaitS ?? 25, stop.signal);
|
|
34
|
+
lastContact = Date.now();
|
|
35
|
+
if (Array.isArray(polled))
|
|
36
|
+
cmds = polled;
|
|
37
|
+
else {
|
|
38
|
+
cmds = polled.commands ?? [];
|
|
39
|
+
if (polled.cancel) {
|
|
40
|
+
finish('cancelled');
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
if (polled.drain && cmds.length === 0) {
|
|
44
|
+
finish('drained');
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (ended)
|
|
51
|
+
break;
|
|
52
|
+
if (Date.now() - lastContact > limits.lease_s * 1000) {
|
|
53
|
+
finish('lease_lost');
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
await sleep(retryDelay, stop.signal);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const conc = Math.max(1, limits.max_inflight ?? cmds.length);
|
|
60
|
+
let next = 0;
|
|
61
|
+
const worker = async () => {
|
|
62
|
+
while (next < cmds.length && !ended) {
|
|
63
|
+
const cmd = cmds[next++];
|
|
64
|
+
const key = cmd.idempotency_key ?? cmd.id;
|
|
65
|
+
if (inflight.has(key))
|
|
66
|
+
continue;
|
|
67
|
+
inflight.add(key);
|
|
68
|
+
const cached = done.get(key);
|
|
69
|
+
let result;
|
|
70
|
+
try {
|
|
71
|
+
result = cached ? { ...cached, command_id: cmd.id } : await runner.execute(cmd);
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
inflight.delete(key);
|
|
75
|
+
}
|
|
76
|
+
logger.debug('executor command', { op: cmd.op, ok: result.ok, ms: result.duration_ms, err: result.error?.code, msg: result.error?.message, obs: result.observation ? Object.keys(result.observation) : undefined, args: JSON.stringify(cmd.args ?? {}).slice(0, 160), sel: cmd.target?.selector, val: op_preview(result) });
|
|
77
|
+
if (!cached) {
|
|
78
|
+
done.set(key, result);
|
|
79
|
+
commandsRun++;
|
|
80
|
+
if (done.size > 200)
|
|
81
|
+
done.delete(done.keys().next().value);
|
|
82
|
+
}
|
|
83
|
+
// Results are idempotent by command_id on the cloud side, so retrying a post is safe.
|
|
84
|
+
for (let attempt = 0;; attempt++) {
|
|
85
|
+
try {
|
|
86
|
+
await transport.postResults([result]);
|
|
87
|
+
lastContact = Date.now();
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
if (ended || Date.now() - lastContact > limits.lease_s * 1000) {
|
|
92
|
+
finish('lease_lost');
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
await sleep(retryDelay * Math.min(attempt + 1, 5), stop.signal);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
await Promise.all(Array.from({ length: Math.min(conc, cmds.length) }, worker));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
clearInterval(hb);
|
|
105
|
+
await runner.close();
|
|
106
|
+
}
|
|
107
|
+
return { ended: ended ?? 'closed_by_cloud', commandsRun };
|
|
108
|
+
}
|
|
109
|
+
function sleep(ms, signal) {
|
|
110
|
+
return new Promise((r) => {
|
|
111
|
+
const t = setTimeout(r, ms);
|
|
112
|
+
signal.addEventListener('abort', () => { clearTimeout(t); r(); }, { once: true });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function op_preview(r) {
|
|
116
|
+
const v = r.observation && 'value' in r.observation ? r.observation.value : undefined;
|
|
117
|
+
if (v === undefined || r.observation.kind === 'screenshot')
|
|
118
|
+
return undefined;
|
|
119
|
+
try {
|
|
120
|
+
return JSON.stringify(v).slice(0, 200);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export function isLoopbackHost(h) {
|
|
2
|
+
return ['localhost', '127.0.0.1', '[::1]', '::1'].includes(h.toLowerCase());
|
|
3
|
+
}
|
|
4
|
+
export function safeHost(u) {
|
|
5
|
+
try {
|
|
6
|
+
return new URL(u).hostname;
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return u;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function hostAllowed(host, allowed) {
|
|
13
|
+
const h = host.toLowerCase();
|
|
14
|
+
return allowed.some((d) => {
|
|
15
|
+
const a = d.trim().toLowerCase().replace(/^\*?\./, '');
|
|
16
|
+
return a !== '' && (h === a || h.endsWith(`.${a}`));
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** Guard every current and future page of the context. `onBlocked` receives the refused host. */
|
|
20
|
+
export async function installNavigationGuard(context, allowed, onBlocked) {
|
|
21
|
+
const check = (u) => {
|
|
22
|
+
try {
|
|
23
|
+
const h = new URL(u).hostname;
|
|
24
|
+
return hostAllowed(h, allowed) || isLoopbackHost(h);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const guard = async (pg) => {
|
|
31
|
+
const cdp = await context.newCDPSession(pg);
|
|
32
|
+
const mainFrameId = (await cdp.send('Page.getFrameTree')).frameTree.frame.id;
|
|
33
|
+
await cdp.send('Fetch.enable', { patterns: [
|
|
34
|
+
{ urlPattern: '*', resourceType: 'Document', requestStage: 'Request' },
|
|
35
|
+
{ urlPattern: '*', resourceType: 'Document', requestStage: 'Response' },
|
|
36
|
+
] });
|
|
37
|
+
cdp.on('Fetch.requestPaused', (ev) => {
|
|
38
|
+
void (async () => {
|
|
39
|
+
try {
|
|
40
|
+
if (ev.frameId !== mainFrameId) {
|
|
41
|
+
await cdp.send(ev.responseStatusCode ? 'Fetch.continueResponse' : 'Fetch.continueRequest', { requestId: ev.requestId });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (ev.responseStatusCode === undefined) {
|
|
45
|
+
if (!check(ev.request.url)) {
|
|
46
|
+
onBlocked(safeHost(ev.request.url));
|
|
47
|
+
await cdp.send('Fetch.failRequest', { requestId: ev.requestId, errorReason: 'BlockedByClient' });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
await cdp.send('Fetch.continueRequest', { requestId: ev.requestId });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const loc = ev.responseHeaders?.find((h) => h.name.toLowerCase() === 'location')?.value;
|
|
54
|
+
if (ev.responseStatusCode >= 300 && ev.responseStatusCode < 400 && loc) {
|
|
55
|
+
const next = new URL(loc, ev.request.url).toString();
|
|
56
|
+
if (!check(next)) {
|
|
57
|
+
onBlocked(safeHost(next));
|
|
58
|
+
await cdp.send('Fetch.failRequest', { requestId: ev.requestId, errorReason: 'BlockedByClient' });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
await cdp.send('Fetch.continueResponse', { requestId: ev.requestId });
|
|
63
|
+
}
|
|
64
|
+
catch { /* page closed */ }
|
|
65
|
+
})();
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
for (const pg of context.pages())
|
|
69
|
+
await guard(pg);
|
|
70
|
+
context.on('page', (pg) => { void guard(pg).catch(() => { }); });
|
|
71
|
+
}
|
|
@@ -43,11 +43,16 @@ const noProgressTimeoutMs = () => {
|
|
|
43
43
|
*/
|
|
44
44
|
export const EVAL_TEMPLATE_SLUG = 'flow/e2es/app-eval'; // check_app_in_browser
|
|
45
45
|
export const PAGE_PROBE_TEMPLATE_SLUG = 'flow/tools/probe'; // probe_page
|
|
46
|
+
export const LOCAL_BROWSE_TEMPLATE_SLUG = 'flow/tools/browse'; // check_app_in_browser browser:'local' (sense/think/act brain)
|
|
46
47
|
export const RAW_CRAWL_TEMPLATE_SLUG = 'crawl-execution-workflow-template'; // trigger_crawl
|
|
47
48
|
/** The slug the eval-template dispatch pins to (env-overridable). */
|
|
48
49
|
export function getEvalTemplateSlug() {
|
|
49
50
|
return process.env.DEBUGGAI_EVAL_TEMPLATE || EVAL_TEMPLATE_SLUG;
|
|
50
51
|
}
|
|
52
|
+
/** The slug a local-executor check pins to: the browser_use brain cannot drive a local executor (env-overridable). */
|
|
53
|
+
export function getLocalBrowseTemplateSlug() {
|
|
54
|
+
return process.env.DEBUGGAI_LOCAL_TEMPLATE || LOCAL_BROWSE_TEMPLATE_SLUG;
|
|
55
|
+
}
|
|
51
56
|
/** The slug the page-probe dispatch pins to (env-overridable). */
|
|
52
57
|
export function getPageProbeTemplateSlug() {
|
|
53
58
|
return process.env.DEBUGGAI_PROBE_TEMPLATE || PAGE_PROBE_TEMPLATE_SLUG;
|
|
@@ -106,6 +106,14 @@ 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
|
+
browser: {
|
|
110
|
+
type: "string",
|
|
111
|
+
enum: ["remote", "local"],
|
|
112
|
+
description: "Default 'remote' (cloud browser; localhost is tunneled). 'local' runs the clicking in Chrome on THIS machine — no tunnel, no *.localhost limits, much faster — while the cloud agent still decides each step. Needs DEBUGGAI_LOCAL_BROWSER=1 on the MCP server and the backend's local executor enabled. Passwords passed in this call stay on this machine. Page screenshots and text are still sent to the cloud agent. Not available over the remote HTTP MCP."
|
|
113
|
+
},
|
|
114
|
+
headless: { type: "boolean", description: "browser:'local' only. Default true; false shows the Chrome window." },
|
|
115
|
+
useProfile: { type: "boolean", description: "browser:'local' only. Use the MCP-owned logged-in Chrome profile (never your everyday Chrome). Requires allowedDomains." },
|
|
116
|
+
allowedDomains: { type: "array", items: { type: "string" }, description: "browser:'local' only. Extra hostnames (and subdomains) the run may navigate to, beyond the url's own host and localhost. Navigation off this list is refused, redirects included." },
|
|
109
117
|
extraOrigins: {
|
|
110
118
|
type: "array",
|
|
111
119
|
items: { type: "string" },
|
package/dist/types/index.js
CHANGED
|
@@ -64,6 +64,12 @@ 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
|
+
// 'local' runs the clicking in Chrome on THIS machine (no tunnel); the cloud agent still decides each step.
|
|
68
|
+
// Needs DEBUGGAI_LOCAL_BROWSER=1 on the MCP server.
|
|
69
|
+
browser: z.enum(['remote', 'local']).default('remote'),
|
|
70
|
+
headless: z.boolean().optional(),
|
|
71
|
+
useProfile: z.boolean().optional(),
|
|
72
|
+
allowedDomains: z.array(z.string().min(1)).max(32).optional(),
|
|
67
73
|
// Extra LOCAL origins the run may be redirected to (bead w1os.2) — e.g. an OAuth
|
|
68
74
|
// vendor on :4717 (also host.docker.internal:4717) or a callback on :4304. Each is
|
|
69
75
|
// tunnelled and redirects to it are followed through the tunnel. Only origins named
|
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
* Shared ProjectRequired text (platform-y2ob.2): says what was observed and
|
|
3
3
|
* what to pass. Used by check_app_in_browser and trigger_crawl.
|
|
4
4
|
*/
|
|
5
|
-
export function projectRequiredMessage(repoName) {
|
|
5
|
+
export function projectRequiredMessage(repoName, acceptsProjectUuid = true) {
|
|
6
6
|
const observed = repoName
|
|
7
7
|
? `Could not determine the DebuggAI project: no project is linked to git repo "${repoName}".`
|
|
8
8
|
: 'Could not determine the DebuggAI project: this is not a git repo and no repoName was passed.';
|
|
9
9
|
return observed
|
|
10
|
-
+
|
|
10
|
+
+ (acceptsProjectUuid
|
|
11
|
+
? ' Pass `projectUuid` (list yours with the `project` tool, action "list") or `repoName` (the "org/repo" of a linked project).'
|
|
12
|
+
: ' Pass `repoName` (matches a project name, slug or linked "org/repo"; list yours with the `project` tool, action "list") or `environmentId`.');
|
|
11
13
|
}
|
|
12
14
|
/**
|
|
13
15
|
* A repo->project lookup that FAILED (platform-y2ob.4), as opposed to one that
|
|
@@ -15,7 +17,7 @@ export function projectRequiredMessage(repoName) {
|
|
|
15
17
|
* network error says nothing about whether a project exists. Reports the HTTP
|
|
16
18
|
* status and the backend's message verbatim.
|
|
17
19
|
*/
|
|
18
|
-
export function projectLookupFailedPayload(repoName, err) {
|
|
20
|
+
export function projectLookupFailedPayload(repoName, err, acceptsProjectUuid = true) {
|
|
19
21
|
const e = err;
|
|
20
22
|
const statusCode = typeof e?.statusCode === 'number' ? e.statusCode : undefined;
|
|
21
23
|
const reason = err instanceof Error ? err.message : String(err);
|
|
@@ -25,7 +27,9 @@ export function projectLookupFailedPayload(repoName, err) {
|
|
|
25
27
|
return {
|
|
26
28
|
error: 'ProjectLookupFailed',
|
|
27
29
|
message: `Could not look up the DebuggAI project for git repo "${repoName}" (${how}). `
|
|
28
|
-
+ 'This is a lookup failure, not a missing link:
|
|
30
|
+
+ 'This is a lookup failure, not a missing link: '
|
|
31
|
+
+ (acceptsProjectUuid ? 'pass `projectUuid` to skip the lookup, ' : 'pass `environmentId` to skip the lookup, ')
|
|
32
|
+
+ 'or fix the connection and retry.',
|
|
29
33
|
detail: { repoName, ...(statusCode !== undefined ? { statusCode } : {}), ...(e?.networkCode ? { networkCode: e.networkCode } : {}) },
|
|
30
34
|
};
|
|
31
35
|
}
|