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