@agenttool/browser 0.1.0 → 0.3.0

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.
Files changed (46) hide show
  1. package/CLAUDE.md +67 -12
  2. package/README.md +188 -31
  3. package/dist/src/browser.d.ts +27 -2
  4. package/dist/src/browser.d.ts.map +1 -1
  5. package/dist/src/browser.js +413 -98
  6. package/dist/src/browser.js.map +1 -1
  7. package/dist/src/capabilities.d.ts +57 -0
  8. package/dist/src/capabilities.d.ts.map +1 -0
  9. package/dist/src/capabilities.js +125 -0
  10. package/dist/src/capabilities.js.map +1 -0
  11. package/dist/src/cli.d.ts +1 -1
  12. package/dist/src/cli.d.ts.map +1 -1
  13. package/dist/src/cli.js +69 -15
  14. package/dist/src/cli.js.map +1 -1
  15. package/dist/src/config.d.ts +5 -0
  16. package/dist/src/config.d.ts.map +1 -1
  17. package/dist/src/config.js +84 -2
  18. package/dist/src/config.js.map +1 -1
  19. package/dist/src/index.d.ts +6 -1
  20. package/dist/src/index.d.ts.map +1 -1
  21. package/dist/src/index.js +4 -1
  22. package/dist/src/index.js.map +1 -1
  23. package/dist/src/mcp.d.ts +14 -7
  24. package/dist/src/mcp.d.ts.map +1 -1
  25. package/dist/src/mcp.js +89 -66
  26. package/dist/src/mcp.js.map +1 -1
  27. package/dist/src/planning.d.ts +29 -0
  28. package/dist/src/planning.d.ts.map +1 -0
  29. package/dist/src/planning.js +107 -0
  30. package/dist/src/planning.js.map +1 -0
  31. package/dist/src/policy.d.ts +16 -6
  32. package/dist/src/policy.d.ts.map +1 -1
  33. package/dist/src/policy.js +72 -18
  34. package/dist/src/policy.js.map +1 -1
  35. package/dist/src/snapshot.d.ts +6 -0
  36. package/dist/src/snapshot.d.ts.map +1 -1
  37. package/dist/src/snapshot.js +116 -23
  38. package/dist/src/snapshot.js.map +1 -1
  39. package/dist/src/types.d.ts +18 -5
  40. package/dist/src/types.d.ts.map +1 -1
  41. package/dist/src/types.js.map +1 -1
  42. package/dist/src/version.d.ts +6 -0
  43. package/dist/src/version.d.ts.map +1 -0
  44. package/dist/src/version.js +6 -0
  45. package/dist/src/version.js.map +1 -0
  46. package/package.json +2 -2
@@ -3,9 +3,12 @@ import { existsSync } from "node:fs";
3
3
  import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
6
+ import { performance } from "node:perf_hooks";
7
+ import { resolveBrowserCapabilities, } from "./capabilities.js";
6
8
  import { asBrowserError, BrowserError } from "./errors.js";
9
+ import { planBrowserAction, } from "./planning.js";
7
10
  import { BrowserNetworkPolicy, redactHtmlUrlAttributes, redactUrlReferenceForOutput, redactUrlForOutput, redactUrlsInText, } from "./policy.js";
8
- import { boundText, compactAriaSnapshot, intersectsViewport, looksLikeSensitiveControl, parseAriaCandidates, redactAriaSecrets, redactSensitiveInputValues, } from "./snapshot.js";
11
+ import { boundText, compactAriaSnapshot, intersectsViewport, looksLikeSensitiveControl, parseAriaCandidates, parseStructuralAriaCandidates, redactAriaSecrets, redactSensitiveInputValues, } from "./snapshot.js";
9
12
  import { OBSERVATION_SCHEMA, } from "./types.js";
10
13
  export const DEFAULT_BROWSER_LIMITS = Object.freeze({
11
14
  maxSnapshotChars: 24_000,
@@ -20,6 +23,12 @@ const DEFAULT_VIEWPORT = Object.freeze({ width: 1280, height: 720 });
20
23
  const DEFAULT_ACTION_TIMEOUT_MS = 10_000;
21
24
  const DEFAULT_NAVIGATION_TIMEOUT_MS = 30_000;
22
25
  const MAX_RESPONSE_HINT_CHARS = 4_096;
26
+ const MAX_RETAINED_SNAPSHOTS_PER_TAB = 8;
27
+ const MAX_STRUCTURAL_CONTEXT_ELEMENTS = 32;
28
+ const VIEWPORT_SETTLE_INTERVAL_MS = 25;
29
+ const VIEWPORT_SETTLE_MAX_MS = 1_000;
30
+ const VIEWPORT_SETTLE_STABLE_INTERVALS = 2;
31
+ const VIEWPORT_SETTLE_TOLERANCE_PX = 0.5;
23
32
  const RESPONSE_HINT_HEADERS = Object.freeze([
24
33
  "link",
25
34
  "content-location",
@@ -39,9 +48,12 @@ export class AgentBrowser {
39
48
  browser;
40
49
  states = new Map();
41
50
  pageStates = new Map();
51
+ requestPolicyDenials = new Map();
42
52
  activeTabId = null;
43
53
  nextTabNumber = 1;
54
+ requestPolicyDenialSequence = 0;
44
55
  closed = false;
56
+ closePromise = null;
45
57
  operationTail = Promise.resolve();
46
58
  constructor(options, context, browser) {
47
59
  this.sessionId = `session_${randomUUID()}`;
@@ -49,8 +61,7 @@ export class AgentBrowser {
49
61
  this.context = context;
50
62
  this.browser = browser;
51
63
  this.policy = new BrowserNetworkPolicy({
52
- allowPublicWeb: options.allowPublicWeb,
53
- allowLocalNetwork: options.allowLocalNetwork,
64
+ capabilities: options.capabilities,
54
65
  ...(options.resolveHostname
55
66
  ? { resolveHostname: options.resolveHostname }
56
67
  : {}),
@@ -79,7 +90,7 @@ export class AgentBrowser {
79
90
  viewport: normalized.viewport,
80
91
  acceptDownloads: false,
81
92
  ignoreHTTPSErrors: false,
82
- serviceWorkers: "block",
93
+ serviceWorkers: normalized.capabilities.runtime.serviceWorkers,
83
94
  };
84
95
  if (normalized.profile.mode === "persistent") {
85
96
  context = await runtime.launchPersistentContext(normalized.profile.directory, { ...launchOptions, ...contextOptions });
@@ -101,13 +112,7 @@ export class AgentBrowser {
101
112
  }
102
113
  catch (error) {
103
114
  try {
104
- await context?.close();
105
- }
106
- catch {
107
- // Preserve the launch error; cleanup failure is secondary.
108
- }
109
- try {
110
- await browser?.close();
115
+ await closeRuntime(browser, context);
111
116
  }
112
117
  catch {
113
118
  // Preserve the launch error; cleanup failure is secondary.
@@ -122,9 +127,12 @@ export class AgentBrowser {
122
127
  async openUnlocked(url) {
123
128
  this.assertOpen();
124
129
  const destination = await this.policy.assertAllowed(url);
130
+ this.assertOpen();
125
131
  const page = await this.context.newPage();
132
+ this.assertOpen();
126
133
  const state = this.registerPage(page);
127
134
  this.activeTabId = state.id;
135
+ const denialSequence = this.requestPolicyDenialSequence;
128
136
  try {
129
137
  this.clearMainDocumentResponse(state);
130
138
  const response = await page.goto(destination.href, {
@@ -132,9 +140,15 @@ export class AgentBrowser {
132
140
  timeout: this.options.navigationTimeoutMs,
133
141
  });
134
142
  this.queueMainDocumentResponse(state, response);
143
+ const denial = this.requestPolicyDenialAfter(denialSequence, state.id);
144
+ if (denial)
145
+ throw denial;
135
146
  }
136
147
  catch (error) {
137
148
  this.invalidate(state);
149
+ const denial = this.requestPolicyDenialAfter(denialSequence, state.id);
150
+ if (denial)
151
+ throw denial;
138
152
  throw asBrowserError(error, "action_failed", "Navigation was attempted once and did not complete.");
139
153
  }
140
154
  return this.observeUnlocked({ tabId: state.id });
@@ -151,8 +165,12 @@ export class AgentBrowser {
151
165
  }
152
166
  const state = this.getState(options.tabId);
153
167
  this.activeTabId = state.id;
154
- this.invalidate(state);
155
- const snapshotId = `${this.sessionId}:${state.id}:${state.revision}`;
168
+ await this.awaitWindowViewportSettled(state.page);
169
+ this.assertOpen();
170
+ state.revision += 1;
171
+ const observationRevision = state.revision;
172
+ const observationNavigationEpoch = state.navigationEpoch;
173
+ const snapshotId = `${this.sessionId}:${state.id}:${observationRevision}`;
156
174
  const viewport = state.page.viewportSize() ?? this.options.viewport;
157
175
  const includeText = options.includeText ?? true;
158
176
  const maxTextChars = boundedPositiveInteger(options.maxTextChars, this.options.limits.maxTextChars, "maxTextChars", this.options.limits.maxTextChars);
@@ -163,39 +181,63 @@ export class AgentBrowser {
163
181
  timeout: this.options.actionTimeoutMs,
164
182
  });
165
183
  const candidates = parseAriaCandidates(raw).slice(0, this.options.limits.maxSnapshotElements * 3);
166
- const inspected = await Promise.all(candidates.map(async (candidate) => {
167
- const locator = state.page.locator(`aria-ref=${candidate.nativeRef}`);
168
- try {
169
- if ((await locator.count()) !== 1 || !(await locator.isVisible())) {
184
+ const structuralCandidateScanLimit = MAX_STRUCTURAL_CONTEXT_ELEMENTS * 3;
185
+ const allStructuralCandidates = parseStructuralAriaCandidates(raw);
186
+ const structuralCandidates = allStructuralCandidates.slice(0, structuralCandidateScanLimit);
187
+ const [inspected, inspectedStructural] = await Promise.all([
188
+ Promise.all(candidates.map(async (candidate) => {
189
+ const locator = state.page.locator(`aria-ref=${candidate.nativeRef}`);
190
+ try {
191
+ if ((await locator.count()) !== 1 || !(await locator.isVisible())) {
192
+ return null;
193
+ }
194
+ if (!intersectsViewport(await locator.boundingBox(), viewport)) {
195
+ return null;
196
+ }
197
+ const attributeNames = [
198
+ "type",
199
+ "autocomplete",
200
+ "name",
201
+ "id",
202
+ "placeholder",
203
+ "aria-label",
204
+ ];
205
+ const values = await Promise.all(attributeNames.map((name) => locator.getAttribute(name)));
206
+ const attributes = {};
207
+ attributeNames.forEach((name, index) => {
208
+ attributes[name] = values[index] ?? null;
209
+ });
210
+ return {
211
+ candidate,
212
+ secret: looksLikeSensitiveControl(attributes, candidate.name),
213
+ };
214
+ }
215
+ catch {
216
+ // The page changed while it was being observed. Omit that target;
217
+ // never guess a replacement ref.
170
218
  return null;
171
219
  }
172
- if (!intersectsViewport(await locator.boundingBox(), viewport))
220
+ })),
221
+ Promise.all(structuralCandidates.map(async (candidate) => {
222
+ const locator = state.page.locator(`aria-ref=${candidate.nativeRef}`);
223
+ try {
224
+ if ((await locator.count()) !== 1 || !(await locator.isVisible())) {
225
+ return null;
226
+ }
227
+ if (!intersectsViewport(await locator.boundingBox(), viewport)) {
228
+ return null;
229
+ }
230
+ return candidate.nativeRef;
231
+ }
232
+ catch {
233
+ // Structural context is optional. Omit a node whose geometry
234
+ // changed rather than exposing an unbound or guessed context.
173
235
  return null;
174
- const attributeNames = [
175
- "type",
176
- "autocomplete",
177
- "name",
178
- "id",
179
- "placeholder",
180
- "aria-label",
181
- ];
182
- const values = await Promise.all(attributeNames.map((name) => locator.getAttribute(name)));
183
- const attributes = {};
184
- attributeNames.forEach((name, index) => {
185
- attributes[name] = values[index] ?? null;
186
- });
187
- return {
188
- candidate,
189
- secret: looksLikeSensitiveControl(attributes, candidate.name),
190
- };
191
- }
192
- catch {
193
- // The page changed while it was being observed. Omit that target;
194
- // never guess a replacement ref.
195
- return null;
196
- }
197
- }));
236
+ }
237
+ })),
238
+ ]);
198
239
  const visibleRefs = new Set();
240
+ const visibleStructuralRefs = new Set();
199
241
  const secretRefs = new Set();
200
242
  const publicRefs = new Map();
201
243
  for (const item of inspected) {
@@ -207,16 +249,21 @@ export class AgentBrowser {
207
249
  if (item.secret)
208
250
  secretRefs.add(item.candidate.nativeRef);
209
251
  }
252
+ for (const nativeRef of inspectedStructural) {
253
+ if (nativeRef)
254
+ visibleStructuralRefs.add(nativeRef);
255
+ }
210
256
  const sanitizedRaw = redactUrlsInText(redactAriaSecrets(raw, secretRefs));
211
257
  const compact = compactAriaSnapshot(sanitizedRaw, {
212
258
  publicRefs,
213
259
  visibleRefs,
260
+ visibleStructuralRefs,
214
261
  secretRefs,
215
262
  maxChars: this.options.limits.maxSnapshotChars,
216
263
  maxElements: this.options.limits.maxSnapshotElements,
264
+ maxStructuralElements: MAX_STRUCTURAL_CONTEXT_ELEMENTS,
217
265
  });
218
- state.snapshotId = snapshotId;
219
- state.refs = new Map(compact.refs.map((ref) => {
266
+ const snapshotRefs = new Map(compact.refs.map((ref) => {
220
267
  const nativeRef = publicRefsEntries(publicRefs, ref.ref);
221
268
  return [ref.ref, nativeRef];
222
269
  }));
@@ -240,13 +287,15 @@ export class AgentBrowser {
240
287
  : null;
241
288
  if (responseSequence !== state.responseSequence)
242
289
  response = null;
290
+ this.assertObservationCurrent(state, observationRevision, observationNavigationEpoch);
291
+ this.rememberSnapshot(state, snapshotId, snapshotRefs, observationNavigationEpoch);
243
292
  return {
244
293
  schema: OBSERVATION_SCHEMA,
245
294
  sessionId: this.sessionId,
246
295
  snapshotId,
247
296
  tabId: state.id,
248
297
  pageId: state.id,
249
- revision: state.revision,
298
+ revision: observationRevision,
250
299
  url,
251
300
  title,
252
301
  snapshot: compact.snapshot,
@@ -254,7 +303,8 @@ export class AgentBrowser {
254
303
  refs: compact.refs,
255
304
  response,
256
305
  truncated: {
257
- snapshot: compact.truncated.snapshot,
306
+ snapshot: compact.truncated.snapshot
307
+ || allStructuralCandidates.length > structuralCandidateScanLimit,
258
308
  text: textTruncated,
259
309
  elements: compact.truncated.elements
260
310
  || candidates.length
@@ -265,24 +315,37 @@ export class AgentBrowser {
265
315
  };
266
316
  }
267
317
  catch (error) {
268
- state.snapshotId = null;
269
- state.refs.clear();
270
318
  throw asBrowserError(error, "action_failed", "Could not create a bounded browser observation.");
271
319
  }
272
320
  }
273
321
  async act(action) {
274
- return this.withLock(() => this.actUnlocked(action));
322
+ const capturedAction = captureBrowserAction(action);
323
+ return this.withLock(() => this.actUnlocked(capturedAction));
324
+ }
325
+ /** Return the immutable authority manifest selected when this session launched. */
326
+ capabilities() {
327
+ return this.options.capabilities;
328
+ }
329
+ /**
330
+ * Forecast possible consequences without touching the page or executing the
331
+ * action. Planning is advisory and never widens launch-time authority.
332
+ */
333
+ plan(action) {
334
+ this.assertOpen();
335
+ validateAction(action, this.options);
336
+ return planBrowserAction(action, this.options.capabilities);
275
337
  }
276
338
  /**
277
339
  * Atomic convenience for process adapters: no other session operation can
278
340
  * interleave between the one action attempt and its read-only observation.
279
341
  */
280
342
  async actAndObserve(action) {
343
+ const capturedAction = captureBrowserAction(action);
281
344
  return this.withLock(async () => {
282
- const actionResult = await this.actUnlocked(action);
345
+ const actionResult = await this.actUnlocked(capturedAction);
283
346
  try {
284
347
  const observation = await this.observeUnlocked({
285
- ...(action.kind !== "close_tab" && actionResult.tabId
348
+ ...(capturedAction.kind !== "close_tab" && actionResult.tabId
286
349
  ? { tabId: actionResult.tabId }
287
350
  : {}),
288
351
  });
@@ -316,15 +379,20 @@ export class AgentBrowser {
316
379
  return this.closeTab(state);
317
380
  let resolved = null;
318
381
  if ("ref" in action && typeof action.ref === "string") {
319
- resolved = await this.resolveRef(state, action.ref, action.snapshotId, action.kind !== "scroll");
382
+ resolved = await this.resolveRef(state, action.ref, action.snapshotId, action.kind !== "scroll", true);
320
383
  }
321
- if (action.kind === "navigate")
322
- await this.policy.assertAllowed(action.url);
384
+ const navigationDestination = action.kind === "navigate"
385
+ ? await this.policy.assertAllowed(action.url)
386
+ : null;
387
+ this.assertOpen();
388
+ const denialSequence = this.requestPolicyDenialSequence;
323
389
  try {
390
+ if (resolved)
391
+ this.assertResolvedRefCurrent(resolved);
324
392
  switch (action.kind) {
325
393
  case "navigate":
326
394
  this.clearMainDocumentResponse(state);
327
- this.queueMainDocumentResponse(state, await state.page.goto(action.url, {
395
+ this.queueMainDocumentResponse(state, await state.page.goto(navigationDestination.href, {
328
396
  waitUntil: "domcontentloaded",
329
397
  timeout: this.options.navigationTimeoutMs,
330
398
  }));
@@ -377,8 +445,14 @@ export class AgentBrowser {
377
445
  default:
378
446
  throw new BrowserError("invalid_action", "Unsupported browser action.");
379
447
  }
448
+ const denial = this.requestPolicyDenialAfter(denialSequence, state.id);
449
+ if (denial)
450
+ throw denial;
380
451
  }
381
452
  catch (error) {
453
+ const denial = this.requestPolicyDenialAfter(denialSequence, state.id);
454
+ if (denial)
455
+ throw denial;
382
456
  throw asBrowserError(error, "action_failed", "Browser action was attempted once and did not complete.");
383
457
  }
384
458
  finally {
@@ -393,9 +467,12 @@ export class AgentBrowser {
393
467
  this.assertOpen();
394
468
  validateExtractInput(input, this.options.limits.maxExtractChars);
395
469
  const state = this.getState(input.tabId);
470
+ const refTargeted = "ref" in input && typeof input.ref === "string";
471
+ let resolved = null;
396
472
  let locator;
397
- if ("ref" in input && typeof input.ref === "string") {
398
- locator = (await this.resolveRef(state, input.ref, input.snapshotId, false)).locator;
473
+ if (refTargeted) {
474
+ resolved = await this.resolveRef(state, input.ref, input.snapshotId, false, false);
475
+ locator = resolved.locator;
399
476
  }
400
477
  else {
401
478
  locator = state.page.locator(input.selector ?? "body");
@@ -404,16 +481,25 @@ export class AgentBrowser {
404
481
  const rawUrl = state.page.url();
405
482
  try {
406
483
  if (input.format === "links") {
407
- const linkLocator = locator.locator("a[href]");
408
- const count = await linkLocator.count();
484
+ const selfLinkLocator = refTargeted
485
+ ? locator.locator("xpath=self::*[local-name()='a'][@href]")
486
+ : null;
487
+ const selfLinkCount = selfLinkLocator
488
+ ? Math.min(await selfLinkLocator.count(), 1)
489
+ : 0;
490
+ const descendantLinkLocator = locator.locator("a[href]");
491
+ const descendantLinkCount = await descendantLinkLocator.count();
492
+ const count = selfLinkCount + descendantLinkCount;
409
493
  const links = [];
410
494
  let usedChars = 0;
411
495
  let truncated = count > this.options.limits.maxExtractLinks;
412
496
  const limit = Math.min(count, this.options.limits.maxExtractLinks);
413
497
  for (let index = 0; index < limit; index += 1) {
414
- const link = linkLocator.nth(index);
498
+ const link = index < selfLinkCount
499
+ ? selfLinkLocator.nth(index)
500
+ : descendantLinkLocator.nth(index - selfLinkCount);
415
501
  const href = await link.getAttribute("href");
416
- if (!href)
502
+ if (href === null)
417
503
  continue;
418
504
  const text = redactUrlsInText((await link.textContent())?.trim() ?? "");
419
505
  const resolvedHref = redactUrlForOutput(safeResolveUrl(href, rawUrl));
@@ -425,6 +511,9 @@ export class AgentBrowser {
425
511
  usedChars += chars;
426
512
  links.push({ text, href: resolvedHref });
427
513
  }
514
+ if (resolved)
515
+ this.assertResolvedRefCurrent(resolved);
516
+ this.assertOpen();
428
517
  return {
429
518
  format: input.format,
430
519
  sessionId: this.sessionId,
@@ -442,6 +531,9 @@ export class AgentBrowser {
442
531
  ? redactHtmlUrlAttributes(redactSensitiveInputValues(await locator.innerHTML()))
443
532
  : await locator.innerText();
444
533
  const bounded = boundText(redactUrlsInText(rawContent), maxChars);
534
+ if (resolved)
535
+ this.assertResolvedRefCurrent(resolved);
536
+ this.assertOpen();
445
537
  return {
446
538
  format: input.format,
447
539
  sessionId: this.sessionId,
@@ -475,8 +567,11 @@ export class AgentBrowser {
475
567
  const artifactPath = join(this.options.outputDir, `${timestamp}-${state.id}-${randomUUID()}.png`);
476
568
  try {
477
569
  await validateCanonicalStoragePaths(this.options.profile, this.options.outputDir);
570
+ this.assertOpen();
478
571
  await ensurePrivateDirectory(this.options.outputDir, "artifact");
572
+ this.assertOpen();
479
573
  await validateCanonicalStoragePaths(this.options.profile, this.options.outputDir);
574
+ this.assertOpen();
480
575
  const bytes = await state.page.screenshot({
481
576
  path: artifactPath,
482
577
  fullPage,
@@ -484,6 +579,7 @@ export class AgentBrowser {
484
579
  });
485
580
  if (process.platform !== "win32")
486
581
  await chmod(artifactPath, 0o600);
582
+ this.assertOpen();
487
583
  const rawUrl = state.page.url();
488
584
  return {
489
585
  sessionId: this.sessionId,
@@ -519,42 +615,53 @@ export class AgentBrowser {
519
615
  title: boundText(redactUrlsInText(await state.page.title()), 512).value,
520
616
  active: state.id === this.activeTabId,
521
617
  });
618
+ this.assertOpen();
522
619
  }
523
620
  return summaries;
524
621
  }
525
- async close() {
526
- return this.withLock(() => this.closeUnlocked());
527
- }
528
- async closeUnlocked() {
529
- if (this.closed)
530
- return;
622
+ close() {
623
+ if (this.closePromise)
624
+ return this.closePromise;
531
625
  this.closed = true;
532
626
  this.states.clear();
533
627
  this.pageStates.clear();
534
- try {
535
- await this.context.close();
536
- }
537
- finally {
538
- await this.browser?.close();
539
- }
628
+ this.requestPolicyDenials.clear();
629
+ const closing = Promise.resolve().then(() => closeRuntime(this.browser, this.context));
630
+ this.closePromise = closing;
631
+ return closing;
540
632
  }
541
633
  async installRequestPolicy() {
542
634
  await this.context.route("**/*", async (route) => {
635
+ const request = route.request();
636
+ try {
637
+ await this.policy.assertAllowed(request.url());
638
+ }
639
+ catch (error) {
640
+ this.recordMainFrameRequestPolicyDenial(request, asBrowserError(error, "network_blocked", "Browser request was denied by the launch-time network policy."));
641
+ await route.abort("blockedbyclient");
642
+ return;
643
+ }
543
644
  try {
544
- await this.policy.assertAllowed(route.request().url());
545
645
  await route.continue();
546
646
  }
547
647
  catch {
548
648
  await route.abort("blockedbyclient");
549
649
  }
550
650
  });
551
- // HTTP routing does not cover WebSockets. V0 blocks every WebSocket
552
- // connection instead of claiming the HTTP(S) DNS policy covers it.
553
651
  await this.context.routeWebSocket("**/*", async (route) => {
554
- await route.close({
555
- code: 1008,
556
- reason: "WebSockets are blocked by AgentBrowser policy",
557
- });
652
+ try {
653
+ await this.policy.assertWebSocketAllowed(route.url());
654
+ if (typeof route.connectToServer !== "function") {
655
+ throw new BrowserError("invalid_options", "Browser runtime cannot pass an allowed WebSocket to its server.");
656
+ }
657
+ route.connectToServer();
658
+ }
659
+ catch {
660
+ await route.close({
661
+ code: 1008,
662
+ reason: "WebSocket destination is outside this browser authority",
663
+ });
664
+ }
558
665
  });
559
666
  }
560
667
  refreshPages() {
@@ -562,6 +669,7 @@ export class AgentBrowser {
562
669
  if (state.page.isClosed()) {
563
670
  this.states.delete(state.id);
564
671
  this.pageStates.delete(state.page);
672
+ this.requestPolicyDenials.delete(state.id);
565
673
  }
566
674
  }
567
675
  for (const page of this.context.pages()) {
@@ -581,24 +689,28 @@ export class AgentBrowser {
581
689
  id: `tab_${this.nextTabNumber++}`,
582
690
  page,
583
691
  revision: 0,
584
- snapshotId: null,
585
- refs: new Map(),
692
+ navigationEpoch: 0,
693
+ snapshots: new Map(),
586
694
  response: null,
587
695
  responseDocumentUrl: null,
588
696
  responseCapture: Promise.resolve(),
589
697
  responseSequence: 0,
590
698
  };
699
+ this.watchPageEvents(state);
591
700
  this.pageStates.set(page, state);
592
701
  this.states.set(state.id, state);
593
702
  this.activeTabId = state.id;
594
- this.watchMainDocumentResponses(state);
595
703
  return state;
596
704
  }
597
- watchMainDocumentResponses(state) {
705
+ watchPageEvents(state) {
598
706
  if (typeof state.page.on !== "function"
599
707
  || typeof state.page.mainFrame !== "function") {
600
- return;
708
+ throw new BrowserError("invalid_options", "Browser runtime must support page frame-navigation events and main-frame identity.");
601
709
  }
710
+ state.page.on("framenavigated", () => {
711
+ state.navigationEpoch += 1;
712
+ this.invalidate(state);
713
+ });
602
714
  state.page.on("response", (response) => {
603
715
  try {
604
716
  const request = response.request();
@@ -717,17 +829,34 @@ export class AgentBrowser {
717
829
  }
718
830
  invalidate(state) {
719
831
  state.revision += 1;
720
- state.snapshotId = null;
721
- state.refs.clear();
832
+ state.snapshots.clear();
833
+ }
834
+ rememberSnapshot(state, snapshotId, refs, navigationEpoch) {
835
+ state.snapshots.set(snapshotId, { navigationEpoch, refs });
836
+ while (state.snapshots.size > MAX_RETAINED_SNAPSHOTS_PER_TAB) {
837
+ const oldest = state.snapshots.keys().next().value;
838
+ if (oldest === undefined)
839
+ break;
840
+ state.snapshots.delete(oldest);
841
+ }
722
842
  }
723
- async resolveRef(state, ref, snapshotId, requireEnabled) {
843
+ assertObservationCurrent(state, revision, navigationEpoch) {
844
+ this.assertOpen();
845
+ if (state.revision !== revision
846
+ || state.navigationEpoch !== navigationEpoch) {
847
+ throw new BrowserError("action_failed", "The page navigated while it was being observed; observe the tab again.");
848
+ }
849
+ }
850
+ async resolveRef(state, ref, snapshotId, requireEnabled, requireCurrentViewport) {
724
851
  if (!snapshotId) {
725
852
  throw new BrowserError("snapshot_required", "A snapshotId is required for every ref-targeted operation.");
726
853
  }
727
- if (state.snapshotId !== snapshotId) {
854
+ const snapshot = state.snapshots.get(snapshotId);
855
+ if (!snapshot
856
+ || snapshot.navigationEpoch !== state.navigationEpoch) {
728
857
  throw new BrowserError("stale_snapshot", "The snapshot is stale; observe the tab again before acting.");
729
858
  }
730
- const nativeRef = state.refs.get(ref);
859
+ const nativeRef = snapshot.refs.get(ref);
731
860
  if (!nativeRef) {
732
861
  throw new BrowserError("ref_not_found", "The ref does not belong to this snapshot.");
733
862
  }
@@ -742,16 +871,168 @@ export class AgentBrowser {
742
871
  if (!(await locator.isVisible())) {
743
872
  throw new BrowserError("ref_hidden", "The snapshot ref is no longer visible.");
744
873
  }
874
+ if (requireCurrentViewport
875
+ && !intersectsViewport(await locator.boundingBox(), state.page.viewportSize() ?? this.options.viewport)) {
876
+ throw new BrowserError("stale_snapshot", "The snapshot ref moved outside the current viewport; observe the tab again before acting.");
877
+ }
745
878
  if (requireEnabled && !(await locator.isEnabled())) {
746
879
  throw new BrowserError("ref_disabled", "The snapshot ref is disabled.");
747
880
  }
748
- return { state, locator };
881
+ const resolved = { state, locator, snapshotId, snapshot };
882
+ this.assertResolvedRefCurrent(resolved);
883
+ return resolved;
884
+ }
885
+ assertResolvedRefCurrent(resolved) {
886
+ this.assertOpen();
887
+ if (resolved.state.navigationEpoch !== resolved.snapshot.navigationEpoch
888
+ || resolved.state.snapshots.get(resolved.snapshotId) !== resolved.snapshot) {
889
+ throw new BrowserError("stale_snapshot", "The snapshot is stale; observe the tab again before acting.");
890
+ }
891
+ }
892
+ /**
893
+ * Playwright's wheel dispatch and a page's own smooth scrolling can outlive
894
+ * the action promise. Sample top-level viewport geometry before issuing refs
895
+ * so the observation is less likely to describe an in-flight window scroll.
896
+ * This is deliberately best-effort: a probe failure must not turn an already
897
+ * executed action into a reported action failure.
898
+ */
899
+ async awaitWindowViewportSettled(page) {
900
+ try {
901
+ const documentElement = page.locator("html");
902
+ const deadline = performance.now() + VIEWPORT_SETTLE_MAX_MS;
903
+ const remainingTimeout = () => {
904
+ const remaining = deadline - performance.now();
905
+ return remaining > 0 ? Math.max(1, Math.ceil(remaining)) : null;
906
+ };
907
+ const initialTimeout = remainingTimeout();
908
+ if (initialTimeout === null)
909
+ return;
910
+ let previous = await documentElement.boundingBox({
911
+ timeout: initialTimeout,
912
+ });
913
+ if (!previous)
914
+ return;
915
+ let stableIntervals = 0;
916
+ while (true) {
917
+ const remainingBeforeWait = deadline - performance.now();
918
+ if (remainingBeforeWait <= 0)
919
+ return;
920
+ await page.waitForTimeout(Math.min(VIEWPORT_SETTLE_INTERVAL_MS, remainingBeforeWait));
921
+ const probeTimeout = remainingTimeout();
922
+ if (probeTimeout === null)
923
+ return;
924
+ const current = await documentElement.boundingBox({
925
+ timeout: probeTimeout,
926
+ });
927
+ if (!current)
928
+ return;
929
+ if (Math.abs(current.x - previous.x) <= VIEWPORT_SETTLE_TOLERANCE_PX
930
+ && Math.abs(current.y - previous.y) <= VIEWPORT_SETTLE_TOLERANCE_PX) {
931
+ stableIntervals += 1;
932
+ if (stableIntervals >= VIEWPORT_SETTLE_STABLE_INTERVALS)
933
+ return;
934
+ }
935
+ else {
936
+ stableIntervals = 0;
937
+ }
938
+ previous = current;
939
+ }
940
+ }
941
+ catch {
942
+ // Observation remains available when a custom runtime cannot expose
943
+ // stable document geometry or the page changes during this probe.
944
+ }
945
+ }
946
+ recordMainFrameRequestPolicyDenial(request, error) {
947
+ try {
948
+ if (!request.isNavigationRequest())
949
+ return;
950
+ }
951
+ catch {
952
+ return;
953
+ }
954
+ // A popup's initial navigation can be routed before Playwright has created
955
+ // a Frame object or exposed the Page through context.pages(). Such a
956
+ // request is still a navigation request. Keep only this genuinely
957
+ // unframed race session-ambiguous so a concurrent action can surface
958
+ // uncertainty without guessing which tab created it.
959
+ let frame;
960
+ try {
961
+ frame = request.frame();
962
+ }
963
+ catch {
964
+ this.recordRequestPolicyDenial(error, null);
965
+ return;
966
+ }
967
+ // A denied subframe navigation is real policy enforcement, but it is not
968
+ // the result of the top-level action. Recording it against the tab would
969
+ // falsely turn a successful click into a failed action.
970
+ try {
971
+ if (frame.parentFrame() !== null)
972
+ return;
973
+ }
974
+ catch {
975
+ // A runtime that cannot classify a returned frame has crossed the same
976
+ // attribution boundary as an unregistered popup.
977
+ this.recordRequestPolicyDenial(error, null);
978
+ return;
979
+ }
980
+ try {
981
+ this.refreshPages();
982
+ }
983
+ catch {
984
+ // The policy decision must still be surfaced and the route aborted even
985
+ // if a custom runtime cannot enumerate/register the new page in time.
986
+ this.recordRequestPolicyDenial(error, null);
987
+ return;
988
+ }
989
+ for (const state of this.states.values()) {
990
+ try {
991
+ if (state.page.mainFrame() === frame) {
992
+ this.recordRequestPolicyDenial(error, state.id);
993
+ return;
994
+ }
995
+ }
996
+ catch {
997
+ // A custom runtime may not expose a stable frame identity.
998
+ }
999
+ }
1000
+ // A navigation request can race page registration (notably for popups).
1001
+ // Preserve the policy denial at session scope and report an uncertain
1002
+ // action outcome rather than inventing an attribution.
1003
+ this.recordRequestPolicyDenial(error, null);
1004
+ }
1005
+ recordRequestPolicyDenial(error, tabId) {
1006
+ this.requestPolicyDenialSequence += 1;
1007
+ this.requestPolicyDenials.set(tabId, {
1008
+ sequence: this.requestPolicyDenialSequence,
1009
+ error: tabId === null
1010
+ ? new BrowserError("action_failed", "A navigation request was policy-blocked while an action was pending, but its tab could not be attributed; the current action outcome is uncertain.", { cause: error })
1011
+ : error,
1012
+ tabId,
1013
+ });
1014
+ }
1015
+ requestPolicyDenialAfter(sequence, tabId) {
1016
+ const scoped = this.requestPolicyDenials.get(tabId);
1017
+ const ambiguous = this.requestPolicyDenials.get(null);
1018
+ let latest = null;
1019
+ for (const denial of [scoped, ambiguous]) {
1020
+ if (denial
1021
+ && denial.sequence > sequence
1022
+ && (!latest || denial.sequence > latest.sequence)) {
1023
+ latest = denial;
1024
+ }
1025
+ }
1026
+ return latest?.error ?? null;
749
1027
  }
750
1028
  async newTab(url) {
751
1029
  const destination = url ? await this.policy.assertAllowed(url) : null;
1030
+ this.assertOpen();
752
1031
  const page = await this.context.newPage();
1032
+ this.assertOpen();
753
1033
  const state = this.registerPage(page);
754
1034
  if (destination) {
1035
+ const denialSequence = this.requestPolicyDenialSequence;
755
1036
  try {
756
1037
  this.clearMainDocumentResponse(state);
757
1038
  const response = await page.goto(destination.href, {
@@ -759,9 +1040,15 @@ export class AgentBrowser {
759
1040
  timeout: this.options.navigationTimeoutMs,
760
1041
  });
761
1042
  this.queueMainDocumentResponse(state, response);
1043
+ const denial = this.requestPolicyDenialAfter(denialSequence, state.id);
1044
+ if (denial)
1045
+ throw denial;
762
1046
  }
763
1047
  catch (error) {
764
1048
  this.invalidate(state);
1049
+ const denial = this.requestPolicyDenialAfter(denialSequence, state.id);
1050
+ if (denial)
1051
+ throw denial;
765
1052
  throw asBrowserError(error, "action_failed", "New-tab navigation was attempted once and did not complete.");
766
1053
  }
767
1054
  this.invalidate(state);
@@ -780,6 +1067,7 @@ export class AgentBrowser {
780
1067
  }
781
1068
  this.states.delete(state.id);
782
1069
  this.pageStates.delete(state.page);
1070
+ this.requestPolicyDenials.delete(state.id);
783
1071
  this.refreshPages();
784
1072
  return {
785
1073
  ok: true,
@@ -817,6 +1105,9 @@ export class AgentBrowser {
817
1105
  }
818
1106
  }
819
1107
  withLock(operation) {
1108
+ if (this.closed) {
1109
+ return Promise.reject(new BrowserError("browser_closed", "Browser session is closed."));
1110
+ }
820
1111
  const previous = this.operationTail;
821
1112
  let release;
822
1113
  this.operationTail = new Promise((resolveTurn) => {
@@ -919,16 +1210,31 @@ function authorityContainsUserinfo(reference) {
919
1210
  const authority = authorityEnd < 0 ? remainder : remainder.slice(0, authorityEnd);
920
1211
  return authority.includes("@");
921
1212
  }
1213
+ function captureBrowserAction(action) {
1214
+ const captured = {
1215
+ ...action,
1216
+ };
1217
+ if (Array.isArray(captured.values)) {
1218
+ captured.values = Object.freeze([...captured.values]);
1219
+ }
1220
+ return Object.freeze(captured);
1221
+ }
922
1222
  async function loadDefaultRuntime() {
923
1223
  const playwright = await import("playwright-core");
924
1224
  return playwright.chromium;
925
1225
  }
1226
+ async function closeRuntime(browser, context) {
1227
+ // An ephemeral session owns the Browser process, and Browser.close() closes
1228
+ // its contexts while also terminating a child that a stuck Context.close()
1229
+ // could otherwise orphan. A persistent launch returns only its context.
1230
+ if (browser) {
1231
+ await browser.close();
1232
+ return;
1233
+ }
1234
+ await context?.close();
1235
+ }
926
1236
  function normalizeOptions(options) {
927
- for (const [name, value] of [
928
- ["headless", options.headless],
929
- ["allowPublicWeb", options.allowPublicWeb],
930
- ["allowLocalNetwork", options.allowLocalNetwork],
931
- ]) {
1237
+ for (const [name, value] of [["headless", options.headless]]) {
932
1238
  if (value !== undefined && typeof value !== "boolean") {
933
1239
  throw new BrowserError("invalid_options", `${name} must be a boolean.`);
934
1240
  }
@@ -995,10 +1301,19 @@ function normalizeOptions(options) {
995
1301
  if (profile.mode === "persistent") {
996
1302
  validateDedicatedProfileDirectory(profile.directory, outputDir);
997
1303
  }
1304
+ const capabilities = resolveBrowserCapabilities({
1305
+ ...(options.authority ? { authority: options.authority } : {}),
1306
+ ...(options.allowPublicWeb !== undefined
1307
+ ? { allowPublicWeb: options.allowPublicWeb }
1308
+ : {}),
1309
+ ...(options.allowLocalNetwork !== undefined
1310
+ ? { allowLocalNetwork: options.allowLocalNetwork }
1311
+ : {}),
1312
+ profileMode: profile.mode,
1313
+ });
998
1314
  return {
999
1315
  headless: options.headless ?? true,
1000
- allowPublicWeb: options.allowPublicWeb ?? true,
1001
- allowLocalNetwork: options.allowLocalNetwork ?? false,
1316
+ capabilities,
1002
1317
  profile,
1003
1318
  ...(channel ? { channel } : {}),
1004
1319
  ...(executablePath ? { executablePath } : {}),