@debugg-ai/debugg-ai-mcp 6.1.6 → 6.2.2

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 CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ - Local browser runs are headless by default (check_app_in_browser, probe_page, profile path). `headless: false` is opt-in and now launches minimized and off-screen without taking focus.
11
+
12
+ ### Changed — local browser is the free default; cloud browsers are the paid option
13
+ - `check_app_in_browser`: leave `browser` unset and it uses your own Chrome when one is installed (stdio server), else the cloud browser; `browser: "local"` or `"remote"` still force one. `DEBUGGAI_LOCAL_BROWSER` is gone: asking for `browser: "local"` is the consent, and the allowlist, sealed credentials and MCP-owned profile are unchanged. It is refused on the hosted HTTP server (no machine of yours). On a backend without local executors an unset `browser` falls back to the cloud browser.
14
+ - A plan without cloud browsers gets a structured `PLAN_REQUIRED` error (HTTP 402) with the message, upgrade link, docs link and how to run in your own Chrome, shown as-is and never retried or silently switched. `probe_page` keeps `browser` defaulting to remote.
15
+
16
+ ## [6.2.1] — 2026-11-13
17
+
18
+ > 6.2.0 was never published; this is its first release. 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.
19
+
20
+ ### Added — check_app_in_browser on your local Chrome, cloud brain (`browser: "local"`) (epic m7un)
21
+ - 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.
22
+ - 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.
23
+ - 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.
24
+ - 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).
25
+ - `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.
26
+ - 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.
27
+ - 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).
28
+
10
29
  ### Changed — test_suite `run` retired (platform-g3am)
11
30
  - 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
31
 
package/README.md CHANGED
@@ -23,6 +23,18 @@ clear error on the first localhost-URL call, not a silent hang. Public-URL calls
23
23
  non-browser tool, and `test_suite {action:"run"}` (which uses its own dedicated tunnel and
24
24
  bypasses Caddy entirely) don't need it either way.
25
25
 
26
+ ## Local Chrome click-through (`browser: "local"`) — free on every plan
27
+
28
+ `check_app_in_browser` can run the check in Chrome on your own machine while the DebuggAI cloud brain decides each step (no tunnel needed, works for private and localhost apps):
29
+
30
+ ```
31
+ check_app_in_browser { "url": "http://localhost:4040", "description": "Sign in and add a task", "browser": "local", "username": "me@x.com", "password": "..." }
32
+ ```
33
+
34
+ - **Free on every plan, and the default when Chrome is installed.** Leave `browser` unset and `check_app_in_browser` uses your own Chrome if it finds one (stdio server); otherwise it uses a cloud browser. Passing `browser: "local"` is all the consent it needs: there is no environment variable to set. Cloud browsers through the MCP (`browser: "remote"`) are included in Professional and above; a plan without them gets `PLAN_REQUIRED` (message, upgrade link, docs link, and how to use your own Chrome instead), never a silent switch. Server-run tests are unchanged.
35
+ - Local runs need a backend with local executors enabled; an unset `browser` quietly uses the cloud browser on a backend without them, while `browser: "local"` says so. Not available on the hosted HTTP/OAuth MCP server (no machine of yours to open Chrome on).
36
+ - Every navigation hop must be on the allowlist (the target host plus `allowedDomains`). Passwords you pass stay on your machine: the cloud sees `{{cred:label}}` and the MCP fills the real value locally. Screenshots go to the cloud by default; `DEBUGGAI_LOCAL_SCREENSHOTS=local_only` keeps them local. `useProfile` keeps persistent cookies between runs, not session cookies.
37
+
26
38
  ## Testing a local app (no Tailscale, no port exposure)
27
39
 
28
40
  Start your app on localhost, then pass the `http://localhost:<port>` URL straight to the tool. The MCP
@@ -49,7 +61,7 @@ Verified 2026-11-10: servers bound to `127.0.0.1` only (IPv4 Caddy gateways on :
49
61
 
50
62
  ### Local Chrome capture (opt-in)
51
63
 
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.
64
+ `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). stdio server only; Chrome is found automatically or via `DEBUGGAI_CHROME_PATH`; `playwright-core` is an optional dependency. Local runs are headless by default and never open a visible window; `headless: false` is opt-in and launches minimized and off-screen without taking focus. `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
65
 
54
66
  Get an API key at [debugg.ai](https://debugg.ai), then add to your MCP client config:
55
67
 
@@ -0,0 +1,202 @@
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, LOCAL_UNAVAILABLE_MESSAGE, findChrome, 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
+ /**
35
+ * Local runs are a paid feature. The backend answers a plan that does not include them with
36
+ * HTTP 402 (or a 403 whose code names the plan/entitlement) and a structured body; show its
37
+ * message, upgrade link and docs link as they are and never retry. Returns undefined for any
38
+ * other error.
39
+ */
40
+ export function planRequiredFromError(err, mode = 'local') {
41
+ const e = err;
42
+ const status = e?.statusCode ?? e?.response?.status;
43
+ const raw = (e?.responseData ?? e?.response?.data);
44
+ const body = (raw && typeof raw.error === 'object' && raw.error ? raw.error : raw);
45
+ const pick = (...keys) => { for (const k of keys) {
46
+ const v = body?.[k];
47
+ if (typeof v === 'string' && v)
48
+ return v;
49
+ } return undefined; };
50
+ const code = pick('code', 'error_code', 'errorCode');
51
+ const planish = !!code && /plan|entitle|upgrade|subscription|not_included/i.test(code);
52
+ if (status !== 402 && !(status === 403 && planish))
53
+ return undefined;
54
+ const feature = pick('feature');
55
+ const message = pick('error', 'message', 'detail') ?? (mode === 'remote' ? 'Cloud browsers are not included in your plan.' : 'This run is not included in your plan.');
56
+ const upgradeUrl = pick('upgrade_url', 'upgradeUrl');
57
+ const docsUrl = pick('docs_url', 'docsUrl');
58
+ const required = body?.required_plans ?? body?.requiredPlans;
59
+ // A plan without cloud browsers can still run in the caller's own Chrome, for free. Say how; never fall back silently.
60
+ const alternative = mode === 'remote' ? 'Run it free in your own Chrome: install Chrome and call with browser: "local".' : undefined;
61
+ const hasHint = /browser:\s*["']?local/i.test(message);
62
+ const text = [message, alternative && !hasHint ? alternative : undefined, upgradeUrl ? `Upgrade: ${upgradeUrl}` : undefined, docsUrl ? `Docs: ${docsUrl}` : undefined].filter(Boolean).join(' ');
63
+ return new LocalBrowserError(text, 'PLAN_REQUIRED', { upgradeUrl, docsUrl, ...(feature ? { feature } : {}), ...(required ? { requiredPlans: required } : {}), ...(alternative ? { alternative } : {}) });
64
+ }
65
+ /**
66
+ * Which browser runs this call. An explicit `browser` always wins. Unset: your own Chrome when it is
67
+ * installed, the server is not the hosted one, or the call asks for something only local can do
68
+ * (useProfile); otherwise the cloud browser.
69
+ */
70
+ export function chooseBrowser(input, env) {
71
+ if (input.browser === 'local')
72
+ return { mode: 'local', auto: false };
73
+ if (input.browser === 'remote')
74
+ return { mode: 'remote', auto: false };
75
+ if (input.useProfile)
76
+ return { mode: 'local', auto: false }; // a profile only exists on this machine
77
+ const hosted = env?.hosted ?? !localBrowserEnabled();
78
+ const chrome = env?.chrome ?? !!findChrome();
79
+ return hosted || !chrome ? { mode: 'remote', auto: false } : { mode: 'local', auto: true };
80
+ }
81
+ export function localAllowedDomains(input, startUrl) {
82
+ const startHost = (() => { try {
83
+ return new URL(startUrl).hostname;
84
+ }
85
+ catch {
86
+ return '';
87
+ } })();
88
+ const extra = (input.extraOrigins ?? []).map((o) => { try {
89
+ return new URL(/^\d+$/.test(o) ? `http://localhost:${o}` : o).hostname;
90
+ }
91
+ catch {
92
+ return '';
93
+ } });
94
+ if (input.useProfile)
95
+ return resolveAllowedDomains(input.allowedDomains);
96
+ return [startHost, ...(input.allowedDomains ?? []), ...extra].filter(Boolean);
97
+ }
98
+ export async function prepareLocalRun(args) {
99
+ const { client, input, startUrl, secrets, signal } = args;
100
+ if (!localBrowserEnabled()) {
101
+ throw new LocalBrowserError(LOCAL_UNAVAILABLE_MESSAGE, 'LOCAL_BROWSER_DISABLED');
102
+ }
103
+ const allowed = localAllowedDomains(input, startUrl);
104
+ if (allowed.length === 0) {
105
+ 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');
106
+ }
107
+ const headless = input.headless !== false;
108
+ const screenshotsMode = process.env.DEBUGGAI_LOCAL_SCREENSHOTS === 'local_only' ? 'local_only' : 'cloud';
109
+ // Chrome starts while the session is being created and the workflow queued.
110
+ // Mutable on purpose: stored credentials are decrypted into it once the session exists.
111
+ const liveSecrets = { ...secrets };
112
+ const keys = generateSessionKeys();
113
+ const runnerP = OpRunner.open({ allowedDomains: allowed, headless, useProfile: !!input.useProfile, secrets: liveSecrets, screenshots: screenshotsMode, signal });
114
+ runnerP.catch(() => { });
115
+ let created;
116
+ try {
117
+ created = await client.tx.post('api/v1/executors/sessions', {
118
+ executor: 'local',
119
+ allowedDomains: allowed,
120
+ headless,
121
+ viewport: { w: 1280, h: 800 },
122
+ capture: { screenshots: screenshotsMode },
123
+ // Public half only. The private key never leaves this process.
124
+ executorPublicKey: keys.publicKey,
125
+ ...(args.projectId ? { projectId: args.projectId } : {}),
126
+ ...(input.environmentId ? { environmentId: input.environmentId } : {}),
127
+ ...(input.credentialId ? { credentialId: input.credentialId } : {}),
128
+ ...(input.credentialRole ? { credentialRole: input.credentialRole } : {}),
129
+ });
130
+ }
131
+ catch (err) {
132
+ (await runnerP.catch(() => null))?.close();
133
+ const planErr = planRequiredFromError(err);
134
+ if (planErr)
135
+ throw planErr;
136
+ const status = err?.response?.status ?? err?.statusCode;
137
+ if (status === 404) {
138
+ throw new LocalBrowserError('The DebuggAI backend does not offer local browser execution yet (executor API not enabled for this account).', 'LOCAL_EXECUTOR_UNAVAILABLE');
139
+ }
140
+ throw err;
141
+ }
142
+ const runner = await runnerP;
143
+ if (created.captureMode === 'cloud' || created.captureMode === 'local_only')
144
+ runner.setCaptureMode(created.captureMode);
145
+ if (typeof created.headless === 'boolean' && created.headless !== (headless !== false)) {
146
+ logger.warn(`Server runs this session with headless=${created.headless}; the local browser was already launched with headless=${headless !== false}.`);
147
+ }
148
+ for (const c of created.credentials ?? []) {
149
+ try {
150
+ const pw = keys.open(c.sealed, created.sessionId);
151
+ liveSecrets[c.label] = pw;
152
+ // The backend names the placeholder after the label OR the username depending on the resolution path; accept both.
153
+ if (c.username && !(c.username in liveSecrets))
154
+ liveSecrets[c.username] = pw;
155
+ }
156
+ catch (err) {
157
+ logger.warn(`Could not open stored credential "${c.label}" (${err instanceof Error ? err.message : 'decrypt failed'})`);
158
+ }
159
+ }
160
+ const limits = {
161
+ heartbeat_s: created.limits?.heartbeatS ?? 10,
162
+ lease_s: created.limits?.leaseS ?? 45,
163
+ max_inline_bytes: created.limits?.maxInlineBytes ?? 1_000_000,
164
+ max_batch: created.limits?.maxBatch ?? 1,
165
+ ...(created.limits?.maxInflight ? { max_inflight: created.limits.maxInflight } : {}),
166
+ };
167
+ const transport = new HttpExecutorTransport({
168
+ baseUrl: config.api.baseUrl,
169
+ authorization: `${config.api.tokenType === 'bearer' ? 'Bearer' : 'Token'} ${config.api.key}`,
170
+ sessionId: created.sessionId,
171
+ secret: created.secret,
172
+ });
173
+ runner.setUploader((url, ct, bytes) => transport.upload(url, ct, bytes));
174
+ const stop = new AbortController();
175
+ const onAbort = () => stop.abort();
176
+ signal.addEventListener('abort', onAbort, { once: true });
177
+ let running;
178
+ return {
179
+ sessionId: created.sessionId,
180
+ begin() {
181
+ running = runExecutorSession({
182
+ transport, runner, limits, signal: stop.signal,
183
+ attach: {
184
+ executor_version: 'debugg-ai-mcp',
185
+ ops_supported: OpRunner.supportedOps(),
186
+ browser: { name: 'chrome', headless },
187
+ profile: input.useProfile ? 'mcp_owned' : 'ephemeral',
188
+ credential_labels: Object.keys(liveSecrets),
189
+ },
190
+ }).catch((err) => { logger.warn(`local executor session failed: ${err instanceof Error ? err.message : err}`); return { ended: 'lease_lost', commandsRun: 0 }; });
191
+ },
192
+ async end() {
193
+ signal.removeEventListener('abort', onAbort);
194
+ if (!running) {
195
+ await runner.close();
196
+ return { ended: 'not_started', commandsRun: 0 };
197
+ }
198
+ stop.abort();
199
+ return running;
200
+ },
201
+ };
202
+ }
@@ -291,6 +291,7 @@ export async function probePageHandler(input, context, rawProgressCallback) {
291
291
  const firstTargetUrl = targetContexts[group.indices[0]]?.targetUrl ?? groupTargets[0].url;
292
292
  const contextData = {
293
293
  targetUrl: firstTargetUrl,
294
+ browserChoice: 'remote', // cloud-browser run: lets the backend apply the plan gate
294
295
  targets: group.indices.map(i => ({
295
296
  url: targetContexts[i].targetUrl ?? input.targets[i].url,
296
297
  // Send null (not undefined) for optional fields so the field exists
@@ -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, planRequiredFromError, chooseBrowser } 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
@@ -112,6 +114,7 @@ export async function testPageChangesHandler(input, context, progressCallback) {
112
114
  }
113
115
  async function testPageChangesHandlerInner(input, context, rawProgressCallback) {
114
116
  const startTime = Date.now();
117
+ const originalInput = input; // before local credential placeholders are applied
115
118
  logger.toolStart('check_app_in_browser', input);
116
119
  // Bead 0bq: wrap the progress callback in a circuit-breaker so a single
117
120
  // client-side rejection of a stale progressToken (which would normally
@@ -136,6 +139,16 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
136
139
  : undefined;
137
140
  const client = new DebuggAIServerClient(config.api.key);
138
141
  await client.init();
142
+ // browser:'local': Chrome on this machine does the clicking (no tunnel); passwords stay local.
143
+ const choice = chooseBrowser(input);
144
+ const localMode = choice.mode === 'local';
145
+ let localSecrets = {};
146
+ if (localMode) {
147
+ const l = localizeCredentials(input);
148
+ input = l.input;
149
+ localSecrets = l.secrets;
150
+ }
151
+ let localRun;
139
152
  const originalUrl = resolveTargetUrl(input);
140
153
  let ctx = buildContext(originalUrl);
141
154
  let provision;
@@ -227,14 +240,14 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
227
240
  // is a billed leak, and beats a backend node failing mid-run.
228
241
  if (!projectUuid && !namedEnvironmentId && lookupError !== undefined && repoName) {
229
242
  // The lookup FAILED — not "no project linked" (platform-y2ob.4).
230
- const payload = projectLookupFailedPayload(repoName, lookupError);
243
+ const payload = projectLookupFailedPayload(repoName, lookupError, false);
231
244
  logger.warn(`check_app_in_browser: ${payload.message}`);
232
245
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
233
246
  }
234
247
  if (!projectUuid && !namedEnvironmentId) {
235
248
  const payload = {
236
249
  error: 'ProjectRequired',
237
- message: projectRequiredMessage(repoName),
250
+ message: projectRequiredMessage(repoName, false),
238
251
  };
239
252
  logger.warn(`check_app_in_browser: ${payload.message}`);
240
253
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
@@ -263,7 +276,10 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
263
276
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
264
277
  }
265
278
  }
266
- if (config.devMode) {
279
+ if (localMode) {
280
+ logger.info(`check_app_in_browser: local browser — no tunnel for ${ctx.originalUrl}`);
281
+ }
282
+ else if (config.devMode) {
267
283
  // Dev mode: local backend can reach localhost directly — no tunnel needed.
268
284
  logger.info(`check_app_in_browser: dev mode — using localhost URL directly: ${ctx.originalUrl}`);
269
285
  }
@@ -375,11 +391,14 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
375
391
  // Cache key = the dispatch slug so the cache key and the lookup can never
376
392
  // drift apart (bug clka: the key used to be a decoupled 'app evaluation'
377
393
  // literal while the lookup searched a different string).
378
- const templateUuid = await getCachedTemplateUuid(getEvalTemplateSlug(), async () => {
379
- return client.workflows.findEvaluationTemplate();
394
+ const templateSlug = localMode ? getLocalBrowseTemplateSlug() : getEvalTemplateSlug();
395
+ const templateUuid = await getCachedTemplateUuid(templateSlug, async () => {
396
+ return localMode
397
+ ? client.workflows.findTemplateBySlug(templateSlug)
398
+ : client.workflows.findEvaluationTemplate();
380
399
  });
381
400
  if (!templateUuid) {
382
- throw new Error(`App Evaluation workflow template not found (slug "${getEvalTemplateSlug()}").`);
401
+ throw new Error(`${localMode ? 'Local browse' : 'App Evaluation'} workflow template not found (slug "${templateSlug}").`);
383
402
  }
384
403
  // --- Build context data (camelCase here — axiosTransport auto-converts to snake_case) ---
385
404
  const contextData = {
@@ -395,6 +414,12 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
395
414
  contextData.projectId = projectUuid;
396
415
  }
397
416
  contextData.headless = true; // D7: the MCP always runs headless — no opt-out.
417
+ contextData.browserChoice = localMode ? 'local' : 'remote'; // lets the backend apply the cloud-browser plan gate
418
+ if (localMode) {
419
+ localRun = await prepareLocalRun({ client, input, startUrl: originalUrl, secrets: localSecrets, signal: abortController.signal, projectId: projectUuid || undefined });
420
+ contextData.executorSessionId = localRun.sessionId;
421
+ contextData.goal = contextData.question; // flow/tools/browse reads `goal`
422
+ }
398
423
  // Bead 56kd.6: forward the auth-precondition deep-link intent verbatim per
399
424
  // backend contract sentinal-k8x1f.8 (contextData.auth). Thin relay — express
400
425
  // intent + forward; the backend authenticates then navigates to deepUrl. Only
@@ -504,7 +529,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
504
529
  // 28: Complete
505
530
  const BACKEND_SETUP_END = 6;
506
531
  const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']);
507
- const MAX_RETRIES = getMaxTransientRetries();
532
+ const MAX_RETRIES = localMode ? 0 : getMaxTransientRetries(); // a retry would need a fresh local session
508
533
  let executeResponse;
509
534
  let executionUuid = '';
510
535
  let finalExecution;
@@ -539,6 +564,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
539
564
  // the target forward; the previous attempt already reached a terminal
540
565
  // state, so there is nothing to cancel there).
541
566
  currentExecutionUuid = executionUuid;
567
+ localRun?.begin();
542
568
  logger.info(`Execution queued: ${executionUuid}${attempt > 1 ? ` (retry ${attempt - 1}/${MAX_RETRIES})` : ''}`);
543
569
  // The abort can fire BEFORE anything is queued — while provisioning the
544
570
  // tunnel or resolving the template — and there is no abort check between
@@ -930,6 +956,18 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
930
956
  return tunnelFault ? { content, isError: true } : { content };
931
957
  }
932
958
  catch (error) {
959
+ // Browser was not chosen and this backend has no local executors: use the cloud browser instead.
960
+ if (choice.auto && error instanceof LocalBrowserError && error.code === 'LOCAL_EXECUTOR_UNAVAILABLE') {
961
+ logger.info('check_app_in_browser: local executors are not available on this backend; using the cloud browser');
962
+ return testPageChangesHandlerInner({ ...originalInput, browser: 'remote' }, context, rawProgressCallback);
963
+ }
964
+ const planErr = planRequiredFromError(error, localMode ? 'local' : 'remote');
965
+ if (error instanceof LocalBrowserError || planErr) {
966
+ const lbe = (planErr ?? error);
967
+ const payload = { error: lbe.code, message: lbe.message, ...(lbe.details ?? {}) };
968
+ logger.warn(`check_app_in_browser (local): ${lbe.code}: ${lbe.message}`);
969
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
970
+ }
933
971
  const duration = Date.now() - startTime;
934
972
  logger.toolError('check_app_in_browser', error, duration);
935
973
  if (error instanceof Error && (error.message.includes('not found') || error.message.includes('401'))) {
@@ -939,6 +977,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
939
977
  throw handleExternalServiceError(error, 'DebuggAI', 'test execution');
940
978
  }
941
979
  finally {
980
+ await localRun?.end().catch(() => { });
942
981
  if (requestSignal)
943
982
  requestSignal.removeEventListener('abort', onAbort);
944
983
  // §2.4: release this call's claim on the shared port route (no-op if we
@@ -6,7 +6,8 @@
6
6
  * Everything stays local: screenshots are returned inline and also written under
7
7
  * ~/.debugg-ai/captures (0600).
8
8
  *
9
- * Opt-in at the server: DEBUGGAI_LOCAL_BROWSER=1. With `useProfile`, the browser
9
+ * No server opt-in: asking for `browser: "local"` in the call is the consent (the allowlist stays
10
+ * required). It is unavailable on the hosted HTTP server, which has no machine of yours. With `useProfile`, the browser
10
11
  * uses an MCP-owned persistent profile (~/.debugg-ai/chrome-profile, override with
11
12
  * DEBUGGAI_CHROME_PROFILE_DIR) the user logs into once via `loginToLocalProfile`;
12
13
  * the user's everyday Chrome profile is never touched.
@@ -14,11 +15,14 @@
14
15
  import fs from 'node:fs';
15
16
  import os from 'node:os';
16
17
  import path from 'node:path';
18
+ import { installNavigationGuard, isLoopbackHost } from './navigationGuard.js';
17
19
  export class LocalBrowserError extends Error {
18
20
  code;
19
- constructor(message, code) {
21
+ details;
22
+ constructor(message, code, details) {
20
23
  super(message);
21
24
  this.code = code;
25
+ this.details = details;
22
26
  this.name = 'LocalBrowserError';
23
27
  }
24
28
  }
@@ -39,9 +43,11 @@ export function resolveAllowedDomains(perCall, env = process.env) {
39
43
  const owner = (env.DEBUGGAI_PROFILE_ALLOWED_DOMAINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
40
44
  return owner.length > 0 ? owner : (perCall ?? []).filter(Boolean);
41
45
  }
46
+ /** Local Chrome needs a machine of the caller's: it is available on stdio, never on the hosted HTTP server. */
42
47
  export function localBrowserEnabled(env = process.env) {
43
- return env.DEBUGGAI_LOCAL_BROWSER === '1' || env.DEBUGGAI_LOCAL_BROWSER === 'true';
48
+ return (env.DEBUGGAI_MCP_TRANSPORT || 'stdio').toLowerCase() !== 'http';
44
49
  }
50
+ export const LOCAL_UNAVAILABLE_MESSAGE = 'Local browser runs are not available on the hosted (HTTP) MCP server: there is no machine of yours to open Chrome on. Use browser "remote", or run the MCP on your own machine (stdio).';
45
51
  export function debuggHome(env = process.env) {
46
52
  return env.DEBUGGAI_HOME || path.join(os.homedir(), '.debugg-ai');
47
53
  }
@@ -70,21 +76,15 @@ async function loadPlaywright() {
70
76
  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
77
  }
72
78
  }
73
- function safeHost(u) {
74
- try {
75
- return new URL(u).hostname;
76
- }
77
- catch {
78
- return u;
79
- }
80
- }
81
- function ensurePrivateDir(dir) {
79
+ export function ensurePrivateDir(dir) {
82
80
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
83
81
  }
84
- async function launch(opts) {
82
+ /** Headed runs are opt-in; even then keep the window out of the way: off-screen, minimized, no focus grab. */
83
+ export const HEADED_QUIET_ARGS = ['--start-minimized', '--window-position=-32000,-32000', '--no-first-run', '--no-default-browser-check'];
84
+ export async function launch(opts) {
85
85
  const pw = await loadPlaywright();
86
86
  const executablePath = findChrome();
87
- const base = { headless: opts.headless, ...(executablePath ? { executablePath } : { channel: 'chrome' }) };
87
+ const base = { headless: opts.headless, ...(opts.headless ? {} : { args: HEADED_QUIET_ARGS }), ...(executablePath ? { executablePath } : { channel: 'chrome' }) };
88
88
  try {
89
89
  if (opts.useProfile) {
90
90
  const dir = profileDir();
@@ -127,7 +127,7 @@ function summarize(entries) {
127
127
  }
128
128
  export async function localCapture(o) {
129
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');
130
+ throw new LocalBrowserError(LOCAL_UNAVAILABLE_MESSAGE, 'LOCAL_BROWSER_DISABLED');
131
131
  }
132
132
  const allowed = o.useProfile ? resolveAllowedDomains(o.allowedDomains) : [];
133
133
  if (o.useProfile && allowed.length === 0) {
@@ -139,71 +139,19 @@ export async function localCapture(o) {
139
139
  o.signal?.addEventListener('abort', onAbort, { once: true });
140
140
  try {
141
141
  const page = context.pages()[0] ?? await context.newPage();
142
+ const blocked = [];
142
143
  if (o.useProfile) {
143
- const isLocal = (h) => ['localhost', '127.0.0.1', '[::1]', '::1'].includes(h);
144
144
  const startHost = (() => { try {
145
145
  return new URL(o.url).hostname;
146
146
  }
147
147
  catch {
148
148
  return '';
149
149
  } })();
150
- if (!hostAllowed(startHost, allowed) && !isLocal(startHost)) {
150
+ if (!hostAllowed(startHost, allowed) && !isLoopbackHost(startHost)) {
151
151
  throw new LocalBrowserError(`${startHost || o.url} is not in the allowlist for this profile session.`, 'DOMAIN_NOT_ALLOWED');
152
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(() => { }); });
153
+ await installNavigationGuard(context, allowed, (h) => blocked.push(h));
205
154
  }
206
- const blocked = [];
207
155
  const consoleErrors = [];
208
156
  const net = [];
209
157
  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
+ }