@debugg-ai/debugg-ai-mcp 6.1.5 → 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.
@@ -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
+ }