@alfe.ai/browser 0.1.0 → 0.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/dist/index.cjs CHANGED
@@ -24,6 +24,135 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  let _alfe_ai_remote = require("@alfe.ai/remote");
25
25
  let puppeteer_core = require("puppeteer-core");
26
26
  puppeteer_core = __toESM(puppeteer_core);
27
+ //#region src/boundary.ts
28
+ const MAX_VIEWPORT_WIDTH = 4096;
29
+ const MAX_VIEWPORT_HEIGHT = 4096;
30
+ const MAX_SELECTOR_CHARS = 4096;
31
+ const MAX_EXPRESSION_CHARS = 128 * 1024;
32
+ const MAX_NAVIGATION_URL_CHARS = 8192;
33
+ const MAX_WAIT_MS = 12e4;
34
+ const MAX_RESULT_BYTES = 1024 * 1024;
35
+ const MAX_RESULT_DEPTH = 10;
36
+ const MAX_RESULT_NODES = 2e4;
37
+ const MAX_RESULT_ARRAY_ITEMS = 1e4;
38
+ const MAX_RESULT_STRING_CHARS = 25e4;
39
+ const UNSAFE_KEYS = new Set([
40
+ "__proto__",
41
+ "constructor",
42
+ "prototype"
43
+ ]);
44
+ const FORBIDDEN_CHROME_ARG_PREFIXES = [
45
+ "--allow-file-access-from-files",
46
+ "--disable-web-security",
47
+ "--host-resolver-rules",
48
+ "--load-extension",
49
+ "--remote-debugging-address",
50
+ "--remote-debugging-pipe",
51
+ "--remote-debugging-port",
52
+ "--user-data-dir"
53
+ ];
54
+ function normalizeViewport(width, height, dpr) {
55
+ if (!Number.isInteger(width) || width < 1 || width > 4096) throw new Error(`viewport width must be an integer from 1 to ${String(MAX_VIEWPORT_WIDTH)}`);
56
+ if (!Number.isInteger(height) || height < 1 || height > 4096) throw new Error(`viewport height must be an integer from 1 to ${String(MAX_VIEWPORT_HEIGHT)}`);
57
+ if (!Number.isFinite(dpr) || dpr < .1 || dpr > 4) throw new Error(`viewport dpr must be from 0.1 to ${String(4)}`);
58
+ if (width * height * dpr * dpr > 33554432) throw new Error("viewport exceeds the device-pixel budget");
59
+ return {
60
+ width,
61
+ height,
62
+ dpr
63
+ };
64
+ }
65
+ function validateIdleShutdownMs(value) {
66
+ if (!Number.isInteger(value) || value < 1e3 || value > 1440 * 60 * 1e3) throw new Error("idle shutdown must be an integer from 1000 to 86400000ms");
67
+ return value;
68
+ }
69
+ function validateChromeArgs(values) {
70
+ if (values.length > 64) throw new Error("too many extra Chrome arguments");
71
+ return values.map((value) => {
72
+ const arg = validateString("Chrome argument", value, 4096);
73
+ const key = arg.split("=", 1)[0]?.toLowerCase() ?? "";
74
+ if (FORBIDDEN_CHROME_ARG_PREFIXES.includes(key)) throw new Error(`Chrome argument ${key} is owned by the browser runtime`);
75
+ return arg;
76
+ });
77
+ }
78
+ function validateNavigationUrl(value) {
79
+ const raw = validateString("navigation URL", value, MAX_NAVIGATION_URL_CHARS);
80
+ let parsed;
81
+ try {
82
+ parsed = new URL(raw);
83
+ } catch {
84
+ throw new Error("navigation URL must be an absolute HTTP(S) URL");
85
+ }
86
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "") throw new Error("navigation URL must be an absolute HTTP(S) URL without credentials");
87
+ return parsed.href;
88
+ }
89
+ function validateSelector(value) {
90
+ return validateString("selector", value, MAX_SELECTOR_CHARS);
91
+ }
92
+ function validateTypeText(value) {
93
+ if (value.length > 32768) throw new Error("type text exceeds the character limit");
94
+ return value;
95
+ }
96
+ function validateExpression(value) {
97
+ return validateString("expression", value, MAX_EXPRESSION_CHARS);
98
+ }
99
+ function validateWaitOptions(opts) {
100
+ if ([
101
+ opts.selector !== void 0,
102
+ opts.ms !== void 0,
103
+ opts.urlPattern !== void 0
104
+ ].filter(Boolean).length !== 1) throw new Error("waitFor requires exactly one selector, ms, or urlPattern");
105
+ if (opts.selector !== void 0) return { selector: validateSelector(opts.selector) };
106
+ if (opts.urlPattern !== void 0) return { urlPattern: validateString("URL pattern", opts.urlPattern, MAX_NAVIGATION_URL_CHARS) };
107
+ if (!Number.isInteger(opts.ms) || (opts.ms ?? 0) < 0 || (opts.ms ?? 0) > 12e4) throw new Error(`wait duration must be an integer from 0 to ${String(MAX_WAIT_MS)}ms`);
108
+ return { ms: opts.ms };
109
+ }
110
+ function assertBoundedAutomationResult(value) {
111
+ cloneJson(value, 0, { nodes: 0 });
112
+ let encoded;
113
+ try {
114
+ encoded = JSON.stringify(value);
115
+ } catch {
116
+ throw new Error("browser evaluation result must be JSON serializable");
117
+ }
118
+ if (Buffer.byteLength(encoded, "utf8") > MAX_RESULT_BYTES) throw new Error("browser evaluation result exceeds the byte limit");
119
+ return value;
120
+ }
121
+ function validateString(label, value, maxChars) {
122
+ if (value.length < 1 || value.length > maxChars || containsControlCharacter(value)) throw new Error(`${label} must contain 1 to ${String(maxChars)} non-control characters`);
123
+ return value;
124
+ }
125
+ function cloneJson(value, depth, state) {
126
+ state.nodes += 1;
127
+ if (state.nodes > MAX_RESULT_NODES) throw new Error("browser evaluation result contains too many values");
128
+ if (depth > MAX_RESULT_DEPTH) throw new Error("browser evaluation result exceeds the depth limit");
129
+ if (value === null || typeof value === "boolean") return value;
130
+ if (typeof value === "number") {
131
+ if (!Number.isFinite(value)) throw new Error("browser evaluation result contains a non-finite number");
132
+ return value;
133
+ }
134
+ if (typeof value === "string") {
135
+ if (value.length > MAX_RESULT_STRING_CHARS) throw new Error("browser evaluation result contains an oversized string");
136
+ return value;
137
+ }
138
+ if (Array.isArray(value)) {
139
+ if (value.length > MAX_RESULT_ARRAY_ITEMS) throw new Error("browser evaluation result contains an oversized array");
140
+ return value.map((entry) => cloneJson(entry, depth + 1, state));
141
+ }
142
+ if (typeof value !== "object") throw new Error("browser evaluation result contains a non-JSON value");
143
+ for (const [key, nested] of Object.entries(value)) {
144
+ if (UNSAFE_KEYS.has(key) || key.length < 1 || key.length > 256) throw new Error("browser evaluation result contains an unsafe property name");
145
+ cloneJson(nested, depth + 1, state);
146
+ }
147
+ return value;
148
+ }
149
+ function containsControlCharacter(value) {
150
+ return Array.from(value).some((character) => {
151
+ const codePoint = character.codePointAt(0) ?? 0;
152
+ return codePoint < 32 || codePoint === 127;
153
+ });
154
+ }
155
+ //#endregion
27
156
  //#region src/browser-session.ts
28
157
  /**
29
158
  * BrowserSession — owns the single headless Chrome instance the agent
@@ -33,6 +162,14 @@ puppeteer_core = __toESM(puppeteer_core);
33
162
  * new tabs are what gets screencast, not a stale main window.
34
163
  */
35
164
  const DEFAULT_IDLE_MS = 300 * 1e3;
165
+ /**
166
+ * A page target is worth streaming only once it has a real, navigable http(s)
167
+ * URL. Transient `about:blank` (and `chrome://`, `data:`, blank string) targets
168
+ * are throwaways we must not flip the screencast to.
169
+ */
170
+ function isNavigablePageUrl(url) {
171
+ return url.startsWith("http://") || url.startsWith("https://");
172
+ }
36
173
  const noopLogger$2 = {
37
174
  info: () => {},
38
175
  warn: () => {},
@@ -45,57 +182,101 @@ var BrowserSession = class {
45
182
  launching = null;
46
183
  holds = 0;
47
184
  idleTimer = null;
185
+ generation = 0;
186
+ preparedPages = /* @__PURE__ */ new WeakSet();
187
+ activePageListeners = /* @__PURE__ */ new Set();
48
188
  log;
49
189
  idleMs;
50
190
  constructor(options) {
51
191
  this.options = options;
52
192
  this.log = options.logger ?? noopLogger$2;
53
- this.idleMs = options.idleShutdownMs ?? DEFAULT_IDLE_MS;
193
+ this.idleMs = validateIdleShutdownMs(options.idleShutdownMs ?? DEFAULT_IDLE_MS);
54
194
  }
55
195
  /** Launch Chrome if not already running (idempotent, concurrent-safe). */
56
196
  async ensureLaunched() {
57
197
  if (this.browser) return;
58
198
  if (this.launching) return this.launching;
59
- this.launching = this.doLaunch().finally(() => {
60
- this.launching = null;
199
+ const generation = ++this.generation;
200
+ const launching = this.doLaunch(generation).finally(() => {
201
+ if (this.launching === launching) this.launching = null;
61
202
  });
62
- return this.launching;
203
+ this.launching = launching;
204
+ return launching;
63
205
  }
64
- async doLaunch() {
206
+ async doLaunch(generation) {
65
207
  const args = [
66
208
  "--disable-blink-features=AutomationControlled",
67
209
  ...this.options.noSandbox ? ["--no-sandbox", "--disable-setuid-sandbox"] : [],
68
- ...this.options.extraArgs ?? []
210
+ ...validateChromeArgs(this.options.extraArgs ?? [])
69
211
  ];
70
212
  this.log.info(`Launching Chrome (${this.options.executablePath})`);
71
- this.browser = await puppeteer_core.default.launch({
213
+ const browser = await puppeteer_core.default.launch({
72
214
  executablePath: this.options.executablePath,
73
215
  headless: this.options.headless ?? true,
74
216
  userDataDir: this.options.userDataDir,
75
217
  args
76
218
  });
77
- this.activePage = (await this.browser.pages())[0] ?? await this.browser.newPage();
78
- this.browser.on("targetcreated", (target) => {
79
- target.page().then((page) => {
80
- if (page) {
81
- this.activePage = page;
82
- this.log.debug("Active page switched to new target");
83
- }
84
- }).catch(() => {});
85
- });
86
- this.browser.on("disconnected", () => {
87
- this.browser = null;
88
- this.activePage = null;
89
- });
219
+ try {
220
+ if (!this.isLaunchCurrent(generation, browser)) throw new Error("Browser launch superseded");
221
+ browser.on("disconnected", () => {
222
+ if (this.browser !== browser) return;
223
+ this.generation += 1;
224
+ this.browser = null;
225
+ this.activePage = null;
226
+ });
227
+ const pages = await browser.pages();
228
+ const initialPage = pages[0] ?? await browser.newPage();
229
+ await Promise.all(pages.map((page) => this.preparePage(page)));
230
+ await this.preparePage(initialPage);
231
+ if (!this.isLaunchCurrent(generation, browser)) throw new Error("Browser launch superseded");
232
+ this.browser = browser;
233
+ this.setActivePage(initialPage);
234
+ browser.on("targetcreated", (target) => {
235
+ if (this.browser !== browser || target.type() !== puppeteer_core.TargetType.PAGE) return;
236
+ target.page().then(async (page) => {
237
+ if (!page || this.browser !== browser) return;
238
+ await this.preparePage(page);
239
+ if (this.adoptIfNavigable(page)) return;
240
+ page.on("framenavigated", (frame) => {
241
+ if (this.browser === browser && frame === page.mainFrame()) this.adoptIfNavigable(page);
242
+ });
243
+ }).catch(() => {});
244
+ });
245
+ } catch (error) {
246
+ await browser.close().catch(() => void 0);
247
+ throw error;
248
+ }
249
+ }
250
+ /**
251
+ * Adopt `page` as the streamed active page iff it has a real navigable
252
+ * http(s) URL. Returns whether it was adopted. Guards against flipping the
253
+ * screencast to a transient `about:blank` throwaway target.
254
+ */
255
+ adoptIfNavigable(page) {
256
+ if (page.isClosed() || !isNavigablePageUrl(page.url())) return false;
257
+ if (this.activePage === page) return true;
258
+ this.setActivePage(page);
259
+ this.log.debug("Active page switched to new target");
260
+ return true;
90
261
  }
91
262
  /** The current active page, launching Chrome first if needed. */
92
263
  async getActivePage() {
93
264
  await this.ensureLaunched();
94
265
  if (!this.activePage || this.activePage.isClosed()) {
95
266
  if (!this.browser) throw new Error("Browser not available");
96
- this.activePage = (await this.browser.pages()).find((p) => !p.isClosed()) ?? await this.browser.newPage();
267
+ const page = (await this.browser.pages()).find((candidate) => !candidate.isClosed()) ?? await this.browser.newPage();
268
+ await this.preparePage(page);
269
+ this.setActivePage(page);
97
270
  }
98
- return this.activePage;
271
+ const activePage = this.activePage;
272
+ if (activePage === null) throw new Error("Browser page not available");
273
+ return activePage;
274
+ }
275
+ onActivePageChange(listener) {
276
+ this.activePageListeners.add(listener);
277
+ return () => {
278
+ this.activePageListeners.delete(listener);
279
+ };
99
280
  }
100
281
  /** Prevent idle shutdown while a viewer or op is active. */
101
282
  addHold() {
@@ -130,6 +311,8 @@ var BrowserSession = class {
130
311
  }
131
312
  async shutdown() {
132
313
  this.clearIdleTimer();
314
+ this.generation += 1;
315
+ this.launching = null;
133
316
  const browser = this.browser;
134
317
  this.browser = null;
135
318
  this.activePage = null;
@@ -137,6 +320,40 @@ var BrowserSession = class {
137
320
  await browser.close();
138
321
  } catch {}
139
322
  }
323
+ setActivePage(page) {
324
+ if (this.activePage === page) return;
325
+ this.activePage = page;
326
+ for (const listener of this.activePageListeners) try {
327
+ Promise.resolve(listener(page)).catch((error) => {
328
+ this.log.warn(`Active-page listener failed: ${error instanceof Error ? error.message : String(error)}`);
329
+ });
330
+ } catch (error) {
331
+ this.log.warn(`Active-page listener failed: ${error instanceof Error ? error.message : String(error)}`);
332
+ }
333
+ }
334
+ async preparePage(page) {
335
+ if (this.preparedPages.has(page)) return;
336
+ this.preparedPages.add(page);
337
+ const policy = this.options.isNavigationAllowed;
338
+ if (policy === void 0) return;
339
+ try {
340
+ await page.setRequestInterception(true);
341
+ page.on("request", (request) => {
342
+ if (request.isInterceptResolutionHandled()) return;
343
+ let allowed = false;
344
+ try {
345
+ allowed = policy(request.url());
346
+ } catch {}
347
+ (allowed ? request.continue() : request.abort("blockedbyclient")).catch(() => void 0);
348
+ });
349
+ } catch (error) {
350
+ this.preparedPages.delete(page);
351
+ throw error;
352
+ }
353
+ }
354
+ isLaunchCurrent(generation, browser) {
355
+ return generation === this.generation && browser.connected;
356
+ }
140
357
  };
141
358
  //#endregion
142
359
  //#region src/screencast-pump.ts
@@ -151,6 +368,12 @@ var ScreencastPump = class {
151
368
  running = false;
152
369
  frameSeq = 0;
153
370
  outstanding = null;
371
+ generation = 0;
372
+ viewport = {
373
+ width: 1280,
374
+ height: 720,
375
+ dpr: 1
376
+ };
154
377
  log;
155
378
  quality;
156
379
  ackTimeoutMs;
@@ -159,6 +382,8 @@ var ScreencastPump = class {
159
382
  this.log = options.logger ?? noopLogger$1;
160
383
  this.quality = options.quality ?? 70;
161
384
  this.ackTimeoutMs = options.ackTimeoutMs ?? 2e3;
385
+ if (!Number.isInteger(this.quality) || this.quality < 1 || this.quality > 100) throw new Error("Screencast quality must be an integer from 1 to 100");
386
+ if (!Number.isInteger(this.ackTimeoutMs) || this.ackTimeoutMs < 100 || this.ackTimeoutMs > 3e4) throw new Error("Screencast ack timeout must be an integer from 100 to 30000ms");
162
387
  }
163
388
  get isRunning() {
164
389
  return this.running;
@@ -166,33 +391,60 @@ var ScreencastPump = class {
166
391
  /** Start (or restart) the screencast on the given page. */
167
392
  async start(page, viewport) {
168
393
  await this.stop();
394
+ const generation = ++this.generation;
395
+ this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
169
396
  const cdp = await page.createCDPSession();
397
+ if (generation !== this.generation) {
398
+ await cdp.detach().catch(() => void 0);
399
+ return;
400
+ }
170
401
  this.cdp = cdp;
171
- await cdp.send("Emulation.setDeviceMetricsOverride", {
172
- width: viewport.width,
173
- height: viewport.height,
174
- deviceScaleFactor: viewport.dpr,
175
- mobile: false
176
- });
177
- cdp.on("Page.screencastFrame", (event) => {
178
- this.onCdpFrame(event.data, event.sessionId, event.metadata);
179
- });
180
- await cdp.send("Page.startScreencast", {
181
- format: "jpeg",
182
- quality: this.quality,
183
- maxWidth: Math.round(viewport.width * viewport.dpr),
184
- maxHeight: Math.round(viewport.height * viewport.dpr),
185
- everyNthFrame: 1
186
- });
187
- this.running = true;
188
- this.log.debug("Screencast started");
402
+ try {
403
+ await cdp.send("Emulation.setDeviceMetricsOverride", {
404
+ width: this.viewport.width,
405
+ height: this.viewport.height,
406
+ deviceScaleFactor: this.viewport.dpr,
407
+ mobile: false
408
+ });
409
+ if (generation !== this.generation || this.cdp !== cdp) return;
410
+ cdp.on("Page.screencastFrame", (event) => {
411
+ if (generation !== this.generation || this.cdp !== cdp) return;
412
+ this.onCdpFrame(cdp, generation, event.data, event.sessionId, event.metadata);
413
+ });
414
+ await cdp.send("Page.startScreencast", {
415
+ format: "jpeg",
416
+ quality: this.quality,
417
+ maxWidth: Math.round(this.viewport.width * this.viewport.dpr),
418
+ maxHeight: Math.round(this.viewport.height * this.viewport.dpr),
419
+ everyNthFrame: 1
420
+ });
421
+ if (generation !== this.generation || this.cdp !== cdp) return;
422
+ this.running = true;
423
+ this.log.debug("Screencast started");
424
+ } catch (error) {
425
+ if (this.cdp === cdp) this.cdp = null;
426
+ await cdp.detach().catch(() => void 0);
427
+ throw error;
428
+ }
189
429
  }
190
- onCdpFrame(dataBase64, cdpSessionId, metadata) {
430
+ onCdpFrame(cdp, generation, dataBase64, cdpSessionId, metadata) {
191
431
  if (this.outstanding) this.ackCdp(this.outstanding);
192
- const frameSeq = ++this.frameSeq;
432
+ if (dataBase64.length > Math.ceil(9437184 * 4 / 3) + 4) {
433
+ cdp.send("Page.screencastFrameAck", { sessionId: cdpSessionId }).catch(() => void 0);
434
+ this.log.warn("Dropped oversized browser screencast frame");
435
+ return;
436
+ }
437
+ const jpeg = Buffer.from(dataBase64, "base64");
438
+ if (jpeg.length < 3 || jpeg.length > 9437184 || jpeg[0] !== 255 || jpeg[1] !== 216 || jpeg[2] !== 255) {
439
+ cdp.send("Page.screencastFrameAck", { sessionId: cdpSessionId }).catch(() => void 0);
440
+ this.log.warn("Dropped invalid browser screencast frame");
441
+ return;
442
+ }
443
+ this.frameSeq = this.frameSeq >= 4294967295 ? 1 : this.frameSeq + 1;
444
+ const frameSeq = this.frameSeq;
193
445
  const meta = {
194
- deviceWidth: metadata.deviceWidth ?? 0,
195
- deviceHeight: metadata.deviceHeight ?? 0,
446
+ deviceWidth: metadata.deviceWidth ?? this.viewport.width * this.viewport.dpr,
447
+ deviceHeight: metadata.deviceHeight ?? this.viewport.height * this.viewport.dpr,
196
448
  frameSeq,
197
449
  offsetTop: metadata.offsetTop,
198
450
  pageScaleFactor: metadata.pageScaleFactor,
@@ -204,11 +456,19 @@ var ScreencastPump = class {
204
456
  }, this.ackTimeoutMs);
205
457
  timer.unref();
206
458
  this.outstanding = {
459
+ cdp,
460
+ generation,
207
461
  cdpSessionId,
208
462
  frameSeq,
209
463
  timer
210
464
  };
211
- this.options.onFrame(Buffer.from(dataBase64, "base64"), meta);
465
+ try {
466
+ this.options.onFrame(jpeg, meta);
467
+ } catch (error) {
468
+ this.log.warn(`Screencast consumer failed: ${error instanceof Error ? error.message : String(error)}`);
469
+ const outstanding = this.outstanding;
470
+ if (outstanding.frameSeq === frameSeq) this.ackCdp(outstanding);
471
+ }
212
472
  }
213
473
  /** Viewer acked frame `frameSeq` — release Chrome to send the next frame. */
214
474
  ackFromViewer(frameSeq) {
@@ -217,9 +477,10 @@ var ScreencastPump = class {
217
477
  ackCdp(outstanding) {
218
478
  clearTimeout(outstanding.timer);
219
479
  this.outstanding = null;
220
- if (this.cdp && this.running) this.cdp.send("Page.screencastFrameAck", { sessionId: outstanding.cdpSessionId }).catch(() => {});
480
+ if (this.cdp === outstanding.cdp && this.running && this.generation === outstanding.generation) outstanding.cdp.send("Page.screencastFrameAck", { sessionId: outstanding.cdpSessionId }).catch(() => {});
221
481
  }
222
482
  async stop() {
483
+ this.generation += 1;
223
484
  this.running = false;
224
485
  if (this.outstanding) {
225
486
  clearTimeout(this.outstanding.timer);
@@ -247,14 +508,24 @@ const MOUSE_TYPE = {
247
508
  var InputInjector = class {
248
509
  cdp = null;
249
510
  viewport;
511
+ generation = 0;
250
512
  constructor(viewport) {
251
- this.viewport = viewport;
513
+ this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
252
514
  }
253
515
  async attach(page) {
254
- await this.detach();
255
- this.cdp = await page.createCDPSession();
516
+ const generation = ++this.generation;
517
+ const old = this.cdp;
518
+ this.cdp = null;
519
+ if (old) await old.detach().catch(() => void 0);
520
+ const cdp = await page.createCDPSession();
521
+ if (generation !== this.generation) {
522
+ await cdp.detach().catch(() => void 0);
523
+ return;
524
+ }
525
+ this.cdp = cdp;
256
526
  }
257
527
  async detach() {
528
+ this.generation += 1;
258
529
  const cdp = this.cdp;
259
530
  this.cdp = null;
260
531
  if (cdp) try {
@@ -262,12 +533,12 @@ var InputInjector = class {
262
533
  } catch {}
263
534
  }
264
535
  updateViewport(viewport) {
265
- this.viewport = viewport;
536
+ this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
266
537
  }
267
538
  toCssPx(nx, ny) {
268
539
  return {
269
- x: Math.round(clamp01(nx) * this.viewport.width),
270
- y: Math.round(clamp01(ny) * this.viewport.height)
540
+ x: Math.round(clamp01(nx) * Math.max(0, this.viewport.width - 1)),
541
+ y: Math.round(clamp01(ny) * Math.max(0, this.viewport.height - 1))
271
542
  };
272
543
  }
273
544
  mouse(p) {
@@ -320,64 +591,103 @@ function clamp01(n) {
320
591
  }
321
592
  //#endregion
322
593
  //#region src/automation.ts
594
+ const OPERATION_TIMEOUT_MS = 3e4;
323
595
  var BrowserAutomation = class {
596
+ operationTail = Promise.resolve();
324
597
  constructor(session, turn, isNavigationAllowed) {
325
598
  this.session = session;
326
599
  this.turn = turn;
327
600
  this.isNavigationAllowed = isNavigationAllowed;
328
601
  }
329
602
  async navigate(url) {
330
- await this.turn.acquireAgent();
331
- if (!this.isNavigationAllowed(url)) throw new Error(`Navigation to ${url} blocked by SSRF policy`);
332
- const page = await this.session.getActivePage();
333
- await page.goto(url, { waitUntil: "domcontentloaded" });
334
- return {
335
- url: page.url(),
336
- title: await page.title()
337
- };
603
+ return this.run(async () => {
604
+ const target = validateNavigationUrl(url);
605
+ let allowed = false;
606
+ try {
607
+ allowed = this.isNavigationAllowed(target);
608
+ } catch {}
609
+ if (!allowed) throw new Error("Navigation blocked by browser policy");
610
+ const page = await this.session.getActivePage();
611
+ await page.goto(target, {
612
+ waitUntil: "domcontentloaded",
613
+ timeout: OPERATION_TIMEOUT_MS
614
+ });
615
+ return {
616
+ url: page.url(),
617
+ title: await page.title()
618
+ };
619
+ });
338
620
  }
339
621
  async click(selector) {
340
- await this.turn.acquireAgent();
341
- await (await this.session.getActivePage()).click(selector);
622
+ await this.run(async () => {
623
+ await (await this.session.getActivePage()).click(validateSelector(selector));
624
+ });
342
625
  }
343
626
  async type(selector, text) {
344
- await this.turn.acquireAgent();
345
- await (await this.session.getActivePage()).type(selector, text);
627
+ await this.run(async () => {
628
+ await (await this.session.getActivePage()).type(validateSelector(selector), validateTypeText(text));
629
+ });
346
630
  }
347
631
  async waitFor(opts) {
348
- await this.turn.acquireAgent();
349
- const page = await this.session.getActivePage();
350
- if (opts.selector) await page.waitForSelector(opts.selector);
351
- else if (opts.urlPattern) {
352
- const pattern = opts.urlPattern;
353
- await page.waitForFunction((p) => window.location.href.includes(p), {}, pattern);
354
- } else if (typeof opts.ms === "number") await new Promise((resolve) => setTimeout(resolve, opts.ms));
632
+ await this.run(async () => {
633
+ const normalized = validateWaitOptions(opts);
634
+ const page = await this.session.getActivePage();
635
+ if (normalized.selector !== void 0) await page.waitForSelector(normalized.selector, { timeout: OPERATION_TIMEOUT_MS });
636
+ else if (normalized.urlPattern !== void 0) await page.waitForFunction((pattern) => window.location.href.includes(pattern), { timeout: OPERATION_TIMEOUT_MS }, normalized.urlPattern);
637
+ else await new Promise((resolve) => {
638
+ setTimeout(resolve, normalized.ms ?? 0).unref();
639
+ });
640
+ });
355
641
  }
356
642
  /** One-shot JPEG screenshot (base64) for the agent's own reasoning — distinct
357
643
  * from the continuous screencast stream to viewers. */
358
644
  async screenshot() {
359
- await this.turn.acquireAgent();
360
- return (await this.session.getActivePage()).screenshot({
361
- type: "jpeg",
362
- quality: 70,
363
- encoding: "base64"
645
+ return this.run(async () => {
646
+ const image = await (await this.session.getActivePage()).screenshot({
647
+ type: "jpeg",
648
+ quality: 70,
649
+ encoding: "base64"
650
+ });
651
+ if (image.length > 14680064) throw new Error("Browser screenshot exceeds the byte limit");
652
+ return image;
364
653
  });
365
654
  }
366
655
  /** Evaluate an expression in the page context via CDP (no eval on our side). */
367
656
  async evaluate(expression) {
368
- await this.turn.acquireAgent();
369
- const cdp = await (await this.session.getActivePage()).createCDPSession();
370
- try {
371
- return (await cdp.send("Runtime.evaluate", {
372
- expression,
373
- returnByValue: true,
374
- awaitPromise: true
375
- })).result.value;
376
- } finally {
657
+ return this.run(async () => {
658
+ const cdp = await (await this.session.getActivePage()).createCDPSession();
377
659
  try {
378
- await cdp.detach();
379
- } catch {}
380
- }
660
+ const res = await cdp.send("Runtime.evaluate", {
661
+ expression: validateExpression(expression),
662
+ returnByValue: true,
663
+ awaitPromise: true,
664
+ timeout: OPERATION_TIMEOUT_MS,
665
+ disableBreaks: true
666
+ });
667
+ if (res.exceptionDetails !== void 0) throw new Error("Browser evaluation failed");
668
+ return assertBoundedAutomationResult(res.result.value ?? null);
669
+ } finally {
670
+ try {
671
+ await cdp.detach();
672
+ } catch {}
673
+ }
674
+ });
675
+ }
676
+ /** Wait until all agent operations that were already queued have settled. */
677
+ async waitUntilIdle() {
678
+ await this.operationTail;
679
+ }
680
+ run(operation) {
681
+ const result = this.operationTail.catch(() => void 0).then(async () => {
682
+ await this.turn.acquireAgent();
683
+ try {
684
+ return await operation();
685
+ } finally {
686
+ this.session.touch();
687
+ }
688
+ });
689
+ this.operationTail = result.then(() => void 0, () => void 0);
690
+ return result;
381
691
  }
382
692
  };
383
693
  //#endregion
@@ -412,12 +722,29 @@ var BrowserSurface = class {
412
722
  dpr: 1
413
723
  };
414
724
  handoff = null;
725
+ controllerSessionId = null;
726
+ pendingControllerSessionId = null;
727
+ streamGeneration = 0;
728
+ streamQueue = Promise.resolve();
729
+ closed = false;
730
+ removePageListener;
415
731
  log;
416
732
  constructor(options, sendFrame) {
417
733
  this.options = options;
418
734
  this.sendFrame = sendFrame;
419
735
  this.log = options.logger ?? noopLogger;
420
- this.session = new BrowserSession(options);
736
+ const navigationPolicy = options.isNavigationAllowed ?? ((url) => {
737
+ try {
738
+ validateNavigationUrl(url);
739
+ return true;
740
+ } catch {
741
+ return false;
742
+ }
743
+ });
744
+ this.session = new BrowserSession({
745
+ ...options,
746
+ isNavigationAllowed: navigationPolicy
747
+ });
421
748
  this.turn = new _alfe_ai_remote.TurnController({ onOwnerChange: () => {
422
749
  this.broadcastState();
423
750
  } });
@@ -428,56 +755,87 @@ var BrowserSurface = class {
428
755
  logger: this.log
429
756
  });
430
757
  this.injector = new InputInjector(this.viewport);
431
- this.automation = new BrowserAutomation(this.session, this.turn, options.isNavigationAllowed ?? (() => true));
758
+ this.automation = new BrowserAutomation(this.session, this.turn, navigationPolicy);
759
+ this.removePageListener = this.session.onActivePageChange(() => {
760
+ if (this.viewers.size > 0) this.restartStreaming();
761
+ });
432
762
  }
433
763
  async openSession(sessionId, open) {
764
+ if (this.closed) throw new Error("Browser surface is shut down");
765
+ if (this.viewers.has(sessionId)) {
766
+ this.sendState(sessionId);
767
+ return;
768
+ }
769
+ const firstViewer = this.viewers.size === 0;
434
770
  this.viewers.add(sessionId);
435
771
  this.session.addHold();
436
- if (open.width && open.height) this.viewport = {
437
- width: open.width,
438
- height: open.height,
439
- dpr: open.dpr ?? 1
440
- };
441
- await this.ensureStreaming();
442
- this.sendState(sessionId);
772
+ try {
773
+ if (open.width !== void 0 && open.height !== void 0) this.viewport = normalizeViewport(open.width, open.height, open.dpr ?? 1);
774
+ if (firstViewer) await this.restartStreaming();
775
+ this.sendState(sessionId);
776
+ } catch (error) {
777
+ this.viewers.delete(sessionId);
778
+ this.session.removeHold();
779
+ if (this.viewers.size === 0) {
780
+ this.streamGeneration += 1;
781
+ this.enqueueStreamCleanup();
782
+ }
783
+ throw error;
784
+ }
443
785
  }
444
786
  handleFrame(frame) {
787
+ if (this.closed || !this.viewers.has(frame.sessionId)) return;
445
788
  switch (frame.type) {
446
789
  case _alfe_ai_remote.RemoteFrameType.SCREENCAST_ACK: {
447
- const ack = (0, _alfe_ai_remote.decodeJson)(frame.payload);
790
+ const ack = (0, _alfe_ai_remote.decodeScreencastAckPayload)(frame.payload);
448
791
  if (ack) this.pump.ackFromViewer(ack.frameSeq);
449
792
  this.session.touch();
450
793
  break;
451
794
  }
452
795
  case _alfe_ai_remote.RemoteFrameType.RESIZE: {
453
- const r = (0, _alfe_ai_remote.decodeJson)(frame.payload);
454
- if (r) this.applyResize(r.width, r.height, r.dpr ?? 1);
796
+ const resize = (0, _alfe_ai_remote.decodeResizePayload)(frame.payload);
797
+ if (resize && frame.sessionId === this.controllerSessionId) this.applyResize(resize.width, resize.height, resize.dpr ?? 1);
455
798
  break;
456
799
  }
457
800
  case _alfe_ai_remote.RemoteFrameType.INPUT_MOUSE:
458
- if (this.turn.humanInControl) {
459
- const p = (0, _alfe_ai_remote.decodeJson)(frame.payload);
460
- if (p) this.injector.mouse(p);
801
+ if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
802
+ const input = (0, _alfe_ai_remote.decodeMouseInputPayload)(frame.payload);
803
+ if (input) this.injector.mouse(input);
461
804
  }
462
805
  break;
463
806
  case _alfe_ai_remote.RemoteFrameType.INPUT_WHEEL:
464
- if (this.turn.humanInControl) {
465
- const p = (0, _alfe_ai_remote.decodeJson)(frame.payload);
466
- if (p) this.injector.wheel(p);
807
+ if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
808
+ const input = (0, _alfe_ai_remote.decodeWheelInputPayload)(frame.payload);
809
+ if (input) this.injector.wheel(input);
467
810
  }
468
811
  break;
469
812
  case _alfe_ai_remote.RemoteFrameType.INPUT_KEY:
470
- if (this.turn.humanInControl) {
471
- const p = (0, _alfe_ai_remote.decodeJson)(frame.payload);
472
- if (p) this.injector.key(p);
813
+ if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
814
+ const input = (0, _alfe_ai_remote.decodeKeyInputPayload)(frame.payload);
815
+ if (input) this.injector.key(input);
473
816
  }
474
817
  break;
475
818
  case _alfe_ai_remote.RemoteFrameType.TAKEOVER_REQUEST:
476
- if (this.turn.grantHuman()) this.broadcast(_alfe_ai_remote.RemoteFrameType.TAKEOVER_GRANTED);
477
- else this.sendFrame((0, _alfe_ai_remote.encodeFrame)(_alfe_ai_remote.RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
819
+ if (this.controllerSessionId !== null || this.pendingControllerSessionId !== null) {
820
+ this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(_alfe_ai_remote.RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
821
+ break;
822
+ }
823
+ this.pendingControllerSessionId = frame.sessionId;
824
+ this.automation.waitUntilIdle().then(() => {
825
+ if (this.pendingControllerSessionId !== frame.sessionId || !this.viewers.has(frame.sessionId) || this.closed) return;
826
+ this.pendingControllerSessionId = null;
827
+ if (this.turn.grantHuman()) {
828
+ this.controllerSessionId = frame.sessionId;
829
+ this.broadcast(_alfe_ai_remote.RemoteFrameType.TAKEOVER_GRANTED);
830
+ } else this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(_alfe_ai_remote.RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
831
+ }).catch((error) => {
832
+ if (this.pendingControllerSessionId === frame.sessionId) this.pendingControllerSessionId = null;
833
+ this.log.warn(`Could not grant browser control: ${error instanceof Error ? error.message : String(error)}`);
834
+ this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(_alfe_ai_remote.RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
835
+ });
478
836
  break;
479
837
  case _alfe_ai_remote.RemoteFrameType.RELEASE_CONTROL:
480
- this.releaseToAgent();
838
+ if (frame.sessionId === this.controllerSessionId) this.releaseToAgent();
481
839
  break;
482
840
  default: break;
483
841
  }
@@ -485,24 +843,40 @@ var BrowserSurface = class {
485
843
  closeSession(sessionId) {
486
844
  if (!this.viewers.delete(sessionId)) return;
487
845
  this.session.removeHold();
846
+ if (this.pendingControllerSessionId === sessionId) this.pendingControllerSessionId = null;
847
+ if (this.controllerSessionId === sessionId) this.releaseToAgent();
488
848
  if (this.viewers.size === 0) {
489
- this.pump.stop();
490
- if (this.turn.humanInControl) this.releaseToAgent();
849
+ this.streamGeneration += 1;
850
+ this.enqueueStreamCleanup();
491
851
  }
492
852
  }
493
853
  /**
494
- * Agent tool entry point: hand control to the human and block until they
495
- * release it (RELEASE_CONTROL) or `timeoutMs` elapses. Resolves with the
496
- * final page URL/title so the agent resumes on the same live page.
854
+ * Agent tool entry point: PARK the agent and wait for a human to take over
855
+ * and hand back (RELEASE_CONTROL / viewer teardown after a claim) or for
856
+ * `timeoutMs` to elapse. Resolves with the final page URL/title so the agent
857
+ * resumes on the same live page.
858
+ *
859
+ * Crucially this does NOT grant the human turn up front. The turn is granted
860
+ * only when a human ACTUALLY takes control — i.e. when the controlling
861
+ * (`canControl:true`) viewer sends a `TAKEOVER_REQUEST` frame (see
862
+ * `handleFrame` → `grantHuman()`). Granting at tool-call time made the session
863
+ * "human in control" before anyone had claimed, so a read-only viewer's
864
+ * teardown would fire `releaseToAgent()` and complete the session out from
865
+ * under the user (mislabeled 409 on the real claim; agent only resuming when
866
+ * the tab closed).
497
867
  */
498
868
  async requestHandoff(timeoutMs) {
499
- this.turn.grantHuman();
500
- this.broadcast(_alfe_ai_remote.RemoteFrameType.TAKEOVER_GRANTED);
869
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1e3 || timeoutMs > 1800 * 1e3) throw new Error("Browser handoff timeout must be an integer from 1000 to 1800000ms");
870
+ if (this.handoff) this.releaseToAgent();
501
871
  return new Promise((resolve) => {
502
872
  const timer = setTimeout(() => {
503
873
  this.handoff = null;
504
- this.turn.releaseHuman();
505
- this.broadcast(_alfe_ai_remote.RemoteFrameType.CONTROL_REVOKED);
874
+ this.pendingControllerSessionId = null;
875
+ if (this.turn.humanInControl) {
876
+ this.turn.releaseHuman();
877
+ this.controllerSessionId = null;
878
+ this.broadcast(_alfe_ai_remote.RemoteFrameType.CONTROL_REVOKED);
879
+ }
506
880
  this.currentPageInfo().then(({ url, title }) => {
507
881
  resolve({
508
882
  released: false,
@@ -519,12 +893,43 @@ var BrowserSurface = class {
519
893
  };
520
894
  });
521
895
  }
896
+ /**
897
+ * Keep the shared Chrome alive across an awaiting-human window. Delegates to
898
+ * the session's hold counter (which also backs per-viewer holds). Idempotent
899
+ * and leak-safe when paired with {@link removeHold} in a `finally`.
900
+ */
901
+ addHold() {
902
+ this.session.addHold();
903
+ }
904
+ /** Release a hold taken by {@link addHold}. */
905
+ removeHold() {
906
+ this.session.removeHold();
907
+ }
522
908
  async shutdown() {
523
- await this.pump.stop();
524
- await this.injector.detach();
909
+ if (this.closed) return;
910
+ this.closed = true;
911
+ this.removePageListener();
912
+ this.streamGeneration += 1;
913
+ this.pendingControllerSessionId = null;
914
+ this.controllerSessionId = null;
915
+ const waiter = this.handoff;
916
+ this.handoff = null;
917
+ if (waiter) {
918
+ clearTimeout(waiter.timer);
919
+ waiter.resolve({
920
+ released: false,
921
+ timedOut: true,
922
+ url: "",
923
+ title: ""
924
+ });
925
+ }
926
+ this.turn.releaseHuman();
927
+ await this.enqueueStreamCleanup();
525
928
  await this.session.shutdown();
526
929
  }
527
930
  releaseToAgent() {
931
+ this.pendingControllerSessionId = null;
932
+ this.controllerSessionId = null;
528
933
  this.turn.releaseHuman();
529
934
  const waiter = this.handoff;
530
935
  this.handoff = null;
@@ -540,51 +945,82 @@ var BrowserSurface = class {
540
945
  });
541
946
  }
542
947
  }
543
- async ensureStreaming() {
544
- if (this.pump.isRunning || this.viewers.size === 0) return;
545
- const page = await this.session.getActivePage();
546
- await this.injector.attach(page);
547
- this.injector.updateViewport(this.viewport);
548
- await this.pump.start(page, this.viewport);
948
+ restartStreaming() {
949
+ const generation = ++this.streamGeneration;
950
+ const run = this.streamQueue.catch(() => void 0).then(async () => {
951
+ if (!this.shouldStream(generation)) return;
952
+ try {
953
+ const page = await this.session.getActivePage();
954
+ if (!this.shouldStream(generation)) return;
955
+ await this.injector.attach(page);
956
+ this.injector.updateViewport(this.viewport);
957
+ await this.pump.start(page, this.viewport);
958
+ if (!this.shouldStream(generation)) {
959
+ await this.pump.stop();
960
+ await this.injector.detach();
961
+ }
962
+ } catch (error) {
963
+ await this.pump.stop();
964
+ await this.injector.detach();
965
+ throw error;
966
+ }
967
+ });
968
+ this.streamQueue = run.catch((error) => {
969
+ this.log.warn(`Browser streaming failed: ${error instanceof Error ? error.message : String(error)}`);
970
+ });
971
+ return run;
972
+ }
973
+ enqueueStreamCleanup() {
974
+ const cleanup = this.streamQueue.catch(() => void 0).then(async () => {
975
+ await this.pump.stop();
976
+ await this.injector.detach();
977
+ });
978
+ this.streamQueue = cleanup.catch(() => void 0);
979
+ return cleanup;
980
+ }
981
+ shouldStream(generation) {
982
+ return !this.closed && this.viewers.size > 0 && generation === this.streamGeneration;
549
983
  }
550
984
  async applyResize(width, height, dpr) {
551
- this.viewport = {
552
- width,
553
- height,
554
- dpr
555
- };
985
+ this.viewport = normalizeViewport(width, height, dpr);
556
986
  this.injector.updateViewport(this.viewport);
557
- if (this.pump.isRunning) {
558
- const page = await this.session.getActivePage();
559
- await this.pump.start(page, this.viewport);
560
- }
987
+ if (this.viewers.size > 0) await this.restartStreaming();
561
988
  }
562
989
  broadcastFrame(jpeg, meta) {
563
- for (const sessionId of this.viewers) this.sendFrame((0, _alfe_ai_remote.encodeScreencastFrame)(sessionId, meta, jpeg));
990
+ for (const sessionId of this.viewers) try {
991
+ this.sendFrameSafe((0, _alfe_ai_remote.encodeScreencastFrame)(sessionId, meta, jpeg));
992
+ } catch (error) {
993
+ this.log.warn(`Browser frame encoding failed: ${error instanceof Error ? error.message : String(error)}`);
994
+ }
564
995
  }
565
996
  broadcast(type) {
566
- for (const sessionId of this.viewers) this.sendFrame((0, _alfe_ai_remote.encodeFrame)(type, sessionId));
997
+ for (const sessionId of this.viewers) this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(type, sessionId));
567
998
  }
568
999
  broadcastState() {
569
1000
  for (const sessionId of this.viewers) this.sendState(sessionId);
570
1001
  }
571
1002
  sendState(sessionId) {
572
1003
  this.currentPageInfo().then(({ url, title }) => {
1004
+ if (!this.viewers.has(sessionId) || this.closed) return;
573
1005
  const state = {
574
1006
  surface: "browser",
575
1007
  url,
576
1008
  title,
577
1009
  controller: this.turn.currentOwner
578
1010
  };
579
- this.sendFrame((0, _alfe_ai_remote.encodeJsonFrame)(_alfe_ai_remote.RemoteFrameType.SESSION_STATE, sessionId, state));
1011
+ try {
1012
+ this.sendFrameSafe((0, _alfe_ai_remote.encodeJsonFrame)(_alfe_ai_remote.RemoteFrameType.SESSION_STATE, sessionId, state));
1013
+ } catch (error) {
1014
+ this.log.warn(`Browser state encoding failed: ${error instanceof Error ? error.message : String(error)}`);
1015
+ }
580
1016
  });
581
1017
  }
582
1018
  async currentPageInfo() {
583
1019
  try {
584
1020
  const page = await this.session.getActivePage();
585
1021
  return {
586
- url: page.url(),
587
- title: await page.title()
1022
+ url: page.url().slice(0, 8192),
1023
+ title: (await page.title()).slice(0, 4096)
588
1024
  };
589
1025
  } catch {
590
1026
  return {
@@ -593,6 +1029,13 @@ var BrowserSurface = class {
593
1029
  };
594
1030
  }
595
1031
  }
1032
+ sendFrameSafe(frame) {
1033
+ try {
1034
+ this.sendFrame(frame);
1035
+ } catch (error) {
1036
+ this.log.warn(`Browser frame send failed: ${error instanceof Error ? error.message : String(error)}`);
1037
+ }
1038
+ }
596
1039
  };
597
1040
  //#endregion
598
1041
  exports.BrowserAutomation = BrowserAutomation;