agentic-browser 2.6.2 → 2.7.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.
@@ -393,6 +393,28 @@ async function initTarget(targetWsUrl, userAgent) {
393
393
  conn.close();
394
394
  }
395
395
  }
396
+ /** Called on the iframe element: origin of its content box in top-level viewport coordinates. */
397
+ const FRAME_CONTENT_OFFSET_FUNCTION = `function () {
398
+ const rect = this.getBoundingClientRect();
399
+ const style = getComputedStyle(this);
400
+ let x = rect.left + this.clientLeft + (parseFloat(style.paddingLeft) || 0);
401
+ let y = rect.top + this.clientTop + (parseFloat(style.paddingTop) || 0);
402
+ let doc = this.ownerDocument;
403
+ while (doc) {
404
+ let frameElement = null;
405
+ try {
406
+ frameElement = doc.defaultView && doc.defaultView.frameElement;
407
+ } catch {
408
+ frameElement = null;
409
+ }
410
+ if (!frameElement) break;
411
+ const frameRect = frameElement.getBoundingClientRect();
412
+ x += frameRect.left + frameElement.clientLeft;
413
+ y += frameRect.top + frameElement.clientTop;
414
+ doc = frameElement.ownerDocument;
415
+ }
416
+ return { x, y };
417
+ }`;
396
418
  /**
397
419
  * Run Runtime.evaluate and surface in-page exceptions as errors.
398
420
  * CDP reports page-side throws via `exceptionDetails` on an otherwise successful
@@ -493,6 +515,82 @@ const LOCATOR_RUNTIME_HELPERS = String.raw`
493
515
  return false;
494
516
  }
495
517
 
518
+ const TEXT_INTERACTIVE_SELECTOR = [
519
+ 'a[href]', 'button', 'input[type="button"]', 'input[type="submit"]', 'input[type="reset"]',
520
+ '[role="button"]', '[role="link"]', '[role="menuitem"]', '[role="tab"]', '[role="option"]',
521
+ '[role="checkbox"]', '[role="radio"]', '[role="switch"]', 'summary', 'label', 'option',
522
+ '[onclick]', '[tabindex]'
523
+ ].join(',');
524
+
525
+ function normalizeText(value) {
526
+ return String(value ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
527
+ }
528
+
529
+ function textLabels(el, useInnerText) {
530
+ const labels = [];
531
+ const body = useInnerText && typeof el.innerText === 'string' ? el.innerText : el.textContent;
532
+ if (body) labels.push(normalizeText(body));
533
+ for (const attr of ['aria-label', 'title', 'alt', 'placeholder']) {
534
+ const value = el.getAttribute && el.getAttribute(attr);
535
+ if (value) labels.push(normalizeText(value));
536
+ }
537
+ if (el.tagName && el.tagName.toLowerCase() === 'input' && typeof el.value === 'string' && el.value) {
538
+ labels.push(normalizeText(el.value));
539
+ }
540
+ return labels;
541
+ }
542
+
543
+ function hasBox(el) {
544
+ const rect = el.getBoundingClientRect();
545
+ return rect.width > 0 && rect.height > 0;
546
+ }
547
+
548
+ // text=Foo matches by visible text (exact first, then substring);
549
+ // text="Foo" matches exactly. Interactive and visible elements win.
550
+ function queryByText(root, query) {
551
+ let raw = String(query).trim();
552
+ let exactOnly = false;
553
+ if (raw.length >= 2 && (raw[0] === '"' || raw[0] === "'") && raw[raw.length - 1] === raw[0]) {
554
+ raw = raw.slice(1, -1);
555
+ exactOnly = true;
556
+ }
557
+ const needle = normalizeText(raw);
558
+ if (!needle) return null;
559
+
560
+ function pick(candidates, useInnerText, preferSmallest) {
561
+ let best = null;
562
+ let bestScore = -1;
563
+ let bestSize = Infinity;
564
+ for (const el of candidates) {
565
+ const labels = textLabels(el, useInnerText);
566
+ let score = -1;
567
+ if (labels.some((label) => label === needle)) score = 2;
568
+ else if (!exactOnly && labels.some((label) => label.includes(needle))) score = 1;
569
+ if (score < 0) continue;
570
+ if (hasBox(el)) score += 4;
571
+ const size = preferSmallest ? (el.textContent || '').length : 0;
572
+ if (score > bestScore || (score === bestScore && size < bestSize)) {
573
+ best = el;
574
+ bestScore = score;
575
+ bestSize = size;
576
+ }
577
+ }
578
+ return best;
579
+ }
580
+
581
+ // On equal score the element with the least text is the most specific match.
582
+ const interactive = pick(root.querySelectorAll(TEXT_INTERACTIVE_SELECTOR), true, true);
583
+ if (interactive) return interactive;
584
+ return pick(root.querySelectorAll(root.nodeType === 9 ? 'body *' : '*'), false, true);
585
+ }
586
+
587
+ function querySegment(root, part) {
588
+ if (/^text=/i.test(part)) {
589
+ return queryByText(root, part.slice(5));
590
+ }
591
+ return root.querySelector(part);
592
+ }
593
+
496
594
  function resolveLocator(locator) {
497
595
  const parts = splitLocator(locator);
498
596
  if (parts.length === 0) return null;
@@ -500,7 +598,7 @@ const LOCATOR_RUNTIME_HELPERS = String.raw`
500
598
  let root = document;
501
599
  let element = null;
502
600
  for (let index = 0; index < parts.length; index += 1) {
503
- element = root.querySelector(parts[index]);
601
+ element = querySegment(root, parts[index]);
504
602
  if (!element) return null;
505
603
 
506
604
  if (index === parts.length - 1) {
@@ -556,7 +654,7 @@ const LOCATOR_RUNTIME_HELPERS = String.raw`
556
654
  if (lastError) {
557
655
  throw lastError;
558
656
  }
559
- throw new Error('Selector not found');
657
+ throw new Error('Element not found: ' + locators.join(' | '));
560
658
  }
561
659
 
562
660
  function ensureInView(element) {
@@ -1015,6 +1113,182 @@ var ChromeCdpBrowserController = class {
1015
1113
  return await fn(conn);
1016
1114
  }
1017
1115
  }
1116
+ /**
1117
+ * Split a composed locator at the first iframe whose document the page
1118
+ * cannot reach (cross-origin). Returns null when no such boundary exists.
1119
+ */
1120
+ async splitAtCrossOriginFrame(conn, locator) {
1121
+ if (!locator.includes(">>>")) return null;
1122
+ return (await evaluateOnPage(conn, {
1123
+ expression: buildLocatorRuntimeExpression("locator", locator, `
1124
+ const parts = splitLocator(locator);
1125
+ let root = document;
1126
+ for (let index = 0; index < parts.length - 1; index += 1) {
1127
+ const el = querySegment(root, parts[index]);
1128
+ if (!el) return null;
1129
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
1130
+ if (tag === 'iframe' || tag === 'frame') {
1131
+ let doc = null;
1132
+ try {
1133
+ doc = el.contentDocument;
1134
+ } catch {
1135
+ doc = null;
1136
+ }
1137
+ if (!doc) {
1138
+ return {
1139
+ frame: parts.slice(0, index + 1).join(LOCATOR_SEPARATOR),
1140
+ rest: parts.slice(index + 1).join(LOCATOR_SEPARATOR),
1141
+ };
1142
+ }
1143
+ root = doc;
1144
+ continue;
1145
+ }
1146
+ if (el.shadowRoot) {
1147
+ root = el.shadowRoot;
1148
+ continue;
1149
+ }
1150
+ return null;
1151
+ }
1152
+ return null;
1153
+ `),
1154
+ returnByValue: true,
1155
+ awaitPromise: true
1156
+ })).result.value ?? null;
1157
+ }
1158
+ /**
1159
+ * Work out which frame a payload targets. An explicit `frame` wins;
1160
+ * otherwise a composed selector crossing a cross-origin iframe is split.
1161
+ */
1162
+ async resolveFrameTarget(conn, payload) {
1163
+ if (payload.frame) return {
1164
+ frame: payload.frame,
1165
+ payload
1166
+ };
1167
+ if (!payload.selector) return { payload };
1168
+ const split = await this.splitAtCrossOriginFrame(conn, payload.selector);
1169
+ if (!split) return { payload };
1170
+ const prefix = split.frame + " >>> ";
1171
+ const fallbackSelectors = payload.fallbackSelectors?.map((fallback) => fallback.startsWith(prefix) ? fallback.slice(prefix.length) : fallback);
1172
+ return {
1173
+ frame: split.frame,
1174
+ payload: {
1175
+ ...payload,
1176
+ selector: split.rest,
1177
+ fallbackSelectors
1178
+ }
1179
+ };
1180
+ }
1181
+ /**
1182
+ * Open an evaluation scope inside an iframe. Same-process frames get an
1183
+ * isolated world on the page connection; out-of-process (cross-site) frames
1184
+ * get their own CDP connection to the frame target. Input events still go
1185
+ * to the page connection, translated by `getOffset()`.
1186
+ */
1187
+ async openFrameScope(conn, targetWsUrl, frameLocator) {
1188
+ const objectId = (await evaluateOnPage(conn, {
1189
+ expression: buildLocatorRuntimeExpression("frameLocator", frameLocator, `
1190
+ const el = resolveLocator(frameLocator);
1191
+ if (!el) throw new Error('Frame not found: ' + frameLocator);
1192
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
1193
+ if (tag !== 'iframe' && tag !== 'frame') {
1194
+ throw new Error('Frame locator does not match an iframe: ' + frameLocator);
1195
+ }
1196
+ ensureInView(el);
1197
+ return el;
1198
+ `),
1199
+ awaitPromise: true
1200
+ })).result.objectId;
1201
+ if (!objectId) throw new Error(`Frame not found: ${frameLocator}`);
1202
+ const releaseObject = () => {
1203
+ conn.send("Runtime.releaseObject", { objectId }).catch(() => {});
1204
+ };
1205
+ try {
1206
+ const frameId = (await conn.send("DOM.describeNode", { objectId })).node?.frameId;
1207
+ if (!frameId) throw new Error(`Frame has no document yet: ${frameLocator}`);
1208
+ const getOffset = async () => {
1209
+ return (await conn.send("Runtime.callFunctionOn", {
1210
+ objectId,
1211
+ functionDeclaration: FRAME_CONTENT_OFFSET_FUNCTION,
1212
+ returnByValue: true
1213
+ })).result.value ?? {
1214
+ x: 0,
1215
+ y: 0
1216
+ };
1217
+ };
1218
+ let world = null;
1219
+ try {
1220
+ world = await conn.send("Page.createIsolatedWorld", {
1221
+ frameId,
1222
+ worldName: "agentic-browser"
1223
+ });
1224
+ } catch {
1225
+ world = null;
1226
+ }
1227
+ if (world?.executionContextId !== void 0) {
1228
+ const contextId = world.executionContextId;
1229
+ return {
1230
+ client: {
1231
+ send: (method, params, timeoutMs) => conn.send(method, method === "Runtime.evaluate" ? {
1232
+ ...params,
1233
+ contextId
1234
+ } : params, timeoutMs),
1235
+ waitForEvent: (method, timeoutMs) => conn.waitForEvent(method, timeoutMs),
1236
+ close: () => {}
1237
+ },
1238
+ getOffset,
1239
+ release: releaseObject
1240
+ };
1241
+ }
1242
+ const frameWsUrl = targetWsUrl.replace(/\/devtools\/page\/[^/?#]+/, `/devtools/page/${frameId}`);
1243
+ if (frameWsUrl === targetWsUrl) throw new Error(`Cannot derive a frame target URL from ${targetWsUrl}`);
1244
+ let frameConn;
1245
+ let lastError;
1246
+ const listUrl = targetWsUrl.replace(/^ws(s?):\/\/([^/]+)\/.*$/, "http$1://$2/json/list");
1247
+ for (let attempt = 0; attempt < 4 && !frameConn; attempt++) {
1248
+ if (attempt > 0) {
1249
+ await getJson(listUrl).catch(() => {});
1250
+ if (attempt > 1) await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
1251
+ }
1252
+ try {
1253
+ frameConn = await this.connectionFactory(frameWsUrl);
1254
+ } catch (error) {
1255
+ lastError = error;
1256
+ }
1257
+ }
1258
+ if (!frameConn) throw new Error(`Unable to attach to cross-origin frame ${frameLocator}: ${lastError?.message}`, { cause: lastError });
1259
+ const attached = frameConn;
1260
+ return {
1261
+ client: attached,
1262
+ getOffset,
1263
+ release: () => {
1264
+ releaseObject();
1265
+ try {
1266
+ attached.close();
1267
+ } catch {}
1268
+ }
1269
+ };
1270
+ } catch (error) {
1271
+ releaseObject();
1272
+ throw error;
1273
+ }
1274
+ }
1275
+ /** Run `fn` against the frame a payload targets (the page itself when none). */
1276
+ async withFrameScope(conn, targetWsUrl, payload, fn) {
1277
+ const target = await this.resolveFrameTarget(conn, payload);
1278
+ if (!target.frame) return await fn(conn, payload, async () => ({
1279
+ x: 0,
1280
+ y: 0
1281
+ }));
1282
+ const scope = await this.openFrameScope(conn, targetWsUrl, target.frame);
1283
+ try {
1284
+ return await fn(scope.client, {
1285
+ ...target.payload,
1286
+ frame: void 0
1287
+ }, scope.getOffset);
1288
+ } finally {
1289
+ scope.release();
1290
+ }
1291
+ }
1018
1292
  async navigate(targetWsUrl, url) {
1019
1293
  return await this.withRetry(targetWsUrl, async (conn) => {
1020
1294
  await this.ensureEnabled(targetWsUrl);
@@ -1276,32 +1550,36 @@ var ChromeCdpBrowserController = class {
1276
1550
  if (payload.action === "dialog") return await this.handleDialogAction(targetWsUrl, payload);
1277
1551
  if (payload.action === "click" || payload.action === "hover" || payload.action === "type" || payload.action === "press") return await this.withRetry(targetWsUrl, async (conn) => {
1278
1552
  await this.ensureEnabled(targetWsUrl);
1279
- if (payload.action === "click") {
1280
- const point = await this.preparePointerTarget(conn, payload);
1281
- const nav = await this.armMainFrameNavigationWait(conn);
1282
- await this.dispatchMouseClick(conn, point.x, point.y);
1283
- await nav.result;
1284
- return "clicked";
1285
- }
1286
- if (payload.action === "hover") {
1287
- const point = await this.preparePointerTarget(conn, payload);
1288
- await conn.send("Input.dispatchMouseEvent", {
1289
- type: "mouseMoved",
1290
- x: point.x,
1291
- y: point.y,
1292
- button: "none"
1293
- });
1294
- return "hovered";
1295
- }
1296
- if (payload.action === "press") {
1297
- await this.focusTarget(conn, payload);
1298
- await this.dispatchKeyPress(conn, payload.key);
1299
- return "pressed";
1300
- }
1301
- if (await this.prepareTypeTarget(conn, payload) === "ready") await conn.send("Input.insertText", { text: payload.text ?? "" });
1302
- return "typed";
1553
+ return await this.withFrameScope(conn, targetWsUrl, payload, async (client, scoped, getOffset) => {
1554
+ if (scoped.action === "click") {
1555
+ const local = await this.preparePointerTarget(client, scoped);
1556
+ const offset = await getOffset();
1557
+ const nav = await this.armMainFrameNavigationWait(conn);
1558
+ await this.dispatchMouseClick(conn, local.x + offset.x, local.y + offset.y);
1559
+ await nav.result;
1560
+ return "clicked";
1561
+ }
1562
+ if (scoped.action === "hover") {
1563
+ const local = await this.preparePointerTarget(client, scoped);
1564
+ const offset = await getOffset();
1565
+ await conn.send("Input.dispatchMouseEvent", {
1566
+ type: "mouseMoved",
1567
+ x: local.x + offset.x,
1568
+ y: local.y + offset.y,
1569
+ button: "none"
1570
+ });
1571
+ return "hovered";
1572
+ }
1573
+ if (scoped.action === "press") {
1574
+ await this.focusTarget(client, scoped);
1575
+ await this.dispatchKeyPress(conn, scoped.key);
1576
+ return "pressed";
1577
+ }
1578
+ if (await this.prepareTypeTarget(client, scoped) === "ready") await conn.send("Input.insertText", { text: scoped.text ?? "" });
1579
+ return "typed";
1580
+ });
1303
1581
  }, "connection");
1304
- const expression = buildLocatorRuntimeExpression("payload", payload, `
1582
+ const buildExpression = (scoped) => buildLocatorRuntimeExpression("payload", scoped, `
1305
1583
  if (payload.action === 'waitFor') {
1306
1584
  const timeout = payload.timeoutMs ?? 2000;
1307
1585
  const started = Date.now();
@@ -1348,11 +1626,17 @@ var ChromeCdpBrowserController = class {
1348
1626
  `);
1349
1627
  return await this.withRetry(targetWsUrl, async (conn) => {
1350
1628
  await this.ensureEnabled(targetWsUrl);
1351
- return (await evaluateOnPage(conn, {
1352
- expression,
1353
- returnByValue: true,
1354
- awaitPromise: true
1355
- })).result.value ?? "";
1629
+ return await this.withFrameScope(conn, targetWsUrl, payload, async (client, scoped) => {
1630
+ return (await evaluateOnPage(client, {
1631
+ expression: scoped.action === "evaluate" ? `(async () => {
1632
+ const __agenticResult = await (${scoped.text ?? "undefined"}
1633
+ );
1634
+ return typeof __agenticResult === 'string' ? __agenticResult : JSON.stringify(__agenticResult);
1635
+ })()` : buildExpression(scoped),
1636
+ returnByValue: true,
1637
+ awaitPromise: true
1638
+ })).result.value ?? "";
1639
+ });
1356
1640
  }, "connection");
1357
1641
  }
1358
1642
  async screenshot(targetWsUrl, options) {
@@ -1388,6 +1672,66 @@ var ChromeCdpBrowserController = class {
1388
1672
  })).result.value ?? "";
1389
1673
  });
1390
1674
  }
1675
+ async getPageState(targetWsUrl) {
1676
+ return await this.withRetry(targetWsUrl, async (conn) => {
1677
+ return (await evaluateOnPage(conn, {
1678
+ expression: "({ url: location.href, title: document.title, readyState: document.readyState })",
1679
+ returnByValue: true
1680
+ })).result.value ?? {
1681
+ url: "",
1682
+ title: "",
1683
+ readyState: "loading"
1684
+ };
1685
+ });
1686
+ }
1687
+ async listTabs(cdpUrl) {
1688
+ return (await getJson(`${cdpUrl}/json/list`)).filter((target) => target.type === "page" && target.webSocketDebuggerUrl).map((target) => ({
1689
+ id: target.id,
1690
+ url: target.url ?? "",
1691
+ title: target.title ?? "",
1692
+ targetWsUrl: target.webSocketDebuggerUrl
1693
+ }));
1694
+ }
1695
+ async openTab(cdpUrl) {
1696
+ const endpoint = `${cdpUrl}/json/new?${encodeURIComponent("about:blank")}`;
1697
+ let created;
1698
+ for (const method of ["PUT", "GET"]) try {
1699
+ const response = await fetch(endpoint, {
1700
+ method,
1701
+ signal: AbortSignal.timeout(5e3)
1702
+ });
1703
+ if (!response.ok) continue;
1704
+ created = await response.json();
1705
+ if (created.webSocketDebuggerUrl) break;
1706
+ } catch {}
1707
+ if (!created?.webSocketDebuggerUrl) throw new Error(`Unable to open a new tab via ${cdpUrl}/json/new`);
1708
+ const targetWsUrl = created.webSocketDebuggerUrl;
1709
+ const host = new URL(cdpUrl).host;
1710
+ let userAgent;
1711
+ for (const [wsUrl, ua] of this.userAgentOverrides) if (new URL(wsUrl).host === host) {
1712
+ userAgent = ua;
1713
+ break;
1714
+ }
1715
+ const appliedUa = await initTarget(targetWsUrl, userAgent);
1716
+ if (appliedUa) this.userAgentOverrides.set(targetWsUrl, appliedUa);
1717
+ return {
1718
+ id: created.id,
1719
+ url: created.url ?? "about:blank",
1720
+ title: created.title ?? "",
1721
+ targetWsUrl
1722
+ };
1723
+ }
1724
+ async activateTab(cdpUrl, tabId) {
1725
+ if (!(await fetch(`${cdpUrl}/json/activate/${encodeURIComponent(tabId)}`, { signal: AbortSignal.timeout(5e3) })).ok) throw new Error(`Tab not found: ${tabId}`);
1726
+ }
1727
+ async closeTab(cdpUrl, tabId) {
1728
+ const tab = (await this.listTabs(cdpUrl)).find((candidate) => candidate.id === tabId);
1729
+ if (!tab) throw new Error(`Tab not found: ${tabId}`);
1730
+ this.dropConnection(tab.targetWsUrl);
1731
+ this.userAgentOverrides.delete(tab.targetWsUrl);
1732
+ const response = await fetch(`${cdpUrl}/json/close/${encodeURIComponent(tabId)}`, { signal: AbortSignal.timeout(5e3) });
1733
+ if (!response.ok) throw new Error(`Unable to close tab ${tabId}: HTTP ${response.status}`);
1734
+ }
1391
1735
  async getContent(targetWsUrl, options) {
1392
1736
  if (options.mode === "summary") return await this.getSummaryContent(targetWsUrl, options);
1393
1737
  if (options.mode === "a11y") {
@@ -1773,6 +2117,10 @@ var ChromeCdpBrowserController = class {
1773
2117
  const limit = options.limit ?? 50;
1774
2118
  const scopeSelector = options.selector;
1775
2119
  const roleFilter = options.roles ? new Set(options.roles) : null;
2120
+ const hrefFilter = options.href ? String(options.href).toLowerCase() : '';
2121
+ const textFilter = options.text
2122
+ ? String(options.text).replace(/\\s+/g, ' ').trim().toLowerCase()
2123
+ : '';
1776
2124
  const candidateSelector = [
1777
2125
  'a[href]',
1778
2126
  'button',
@@ -2044,6 +2392,7 @@ var ChromeCdpBrowserController = class {
2044
2392
  }
2045
2393
 
2046
2394
  const results = [];
2395
+ const crossOriginFrames = [];
2047
2396
  let totalFound = 0;
2048
2397
 
2049
2398
  function maybeRecordElement(root, path, el) {
@@ -2061,6 +2410,25 @@ var ChromeCdpBrowserController = class {
2061
2410
  const visible = isVisible(el);
2062
2411
  if (visibleOnly && !visible) return;
2063
2412
 
2413
+ if (hrefFilter) {
2414
+ const href = typeof el.href === 'string' ? el.href : el.getAttribute('href');
2415
+ if (!href || !String(href).toLowerCase().includes(hrefFilter)) return;
2416
+ }
2417
+ if (textFilter) {
2418
+ const haystack = [
2419
+ el.innerText || el.textContent || '',
2420
+ el.getAttribute('aria-label'),
2421
+ el.getAttribute('placeholder'),
2422
+ el.getAttribute('title'),
2423
+ typeof el.value === 'string' ? el.value : '',
2424
+ ]
2425
+ .filter(Boolean)
2426
+ .join(' ')
2427
+ .replace(/\\s+/g, ' ')
2428
+ .toLowerCase();
2429
+ if (!haystack.includes(textFilter)) return;
2430
+ }
2431
+
2064
2432
  totalFound += 1;
2065
2433
  if (results.length >= limit) return;
2066
2434
 
@@ -2104,12 +2472,21 @@ var ChromeCdpBrowserController = class {
2104
2472
  }
2105
2473
 
2106
2474
  if (current.tagName && current.tagName.toLowerCase() === 'iframe') {
2475
+ let frameDocument = null;
2107
2476
  try {
2108
- if (current.contentDocument) {
2109
- walkRoot(current.contentDocument, path.concat(buildSelector(root, current)));
2110
- }
2477
+ frameDocument = current.contentDocument;
2111
2478
  } catch {
2112
- // ignore inaccessible frames
2479
+ frameDocument = null;
2480
+ }
2481
+ if (frameDocument) {
2482
+ walkRoot(frameDocument, path.concat(buildSelector(root, current)));
2483
+ } else if (crossOriginFrames.length < 20 && isVisible(current)) {
2484
+ // Cross-origin: not walkable from here, report it so callers can pass it as frame.
2485
+ const info = { selector: composeLocator(path, buildSelector(root, current)) };
2486
+ const title = current.getAttribute('title');
2487
+ if (title) info.title = title.slice(0, 80);
2488
+ if (current.src) info.src = String(current.src).slice(0, 200);
2489
+ crossOriginFrames.push(info);
2113
2490
  }
2114
2491
  }
2115
2492
 
@@ -2120,7 +2497,9 @@ var ChromeCdpBrowserController = class {
2120
2497
  }
2121
2498
 
2122
2499
  walkRoot(document, []);
2123
- return { elements: results, totalFound, truncated: totalFound > results.length };
2500
+ const output = { elements: results, totalFound, truncated: totalFound > results.length };
2501
+ if (crossOriginFrames.length) output.crossOriginFrames = crossOriginFrames;
2502
+ return output;
2124
2503
  `);
2125
2504
  const emptyResult = {
2126
2505
  elements: [],
@@ -2134,12 +2513,31 @@ var ChromeCdpBrowserController = class {
2134
2513
  };
2135
2514
  return await this.withRetry(targetWsUrl, async (conn) => {
2136
2515
  await this.ensureEnabled(targetWsUrl);
2137
- const result = await evaluateOnPage(conn, {
2516
+ const evaluate = async (client) => extract(await evaluateOnPage(client, {
2138
2517
  expression,
2139
2518
  returnByValue: true,
2140
2519
  awaitPromise: true
2141
- });
2142
- return extract(result);
2520
+ }));
2521
+ if (!options.frame) return await evaluate(conn);
2522
+ const scope = await this.openFrameScope(conn, targetWsUrl, options.frame);
2523
+ try {
2524
+ const inner = await evaluate(scope.client);
2525
+ const withPrefix = (selector) => `${options.frame} >>> ${selector}`;
2526
+ return {
2527
+ ...inner,
2528
+ elements: inner.elements.map((element) => ({
2529
+ ...element,
2530
+ selector: withPrefix(element.selector),
2531
+ fallbackSelectors: element.fallbackSelectors?.map(withPrefix)
2532
+ })),
2533
+ crossOriginFrames: inner.crossOriginFrames?.map((frame) => ({
2534
+ ...frame,
2535
+ selector: withPrefix(frame.selector)
2536
+ }))
2537
+ };
2538
+ } finally {
2539
+ scope.release();
2540
+ }
2143
2541
  });
2144
2542
  }
2145
2543
  /**
@@ -2153,22 +2551,33 @@ var ChromeCdpBrowserController = class {
2153
2551
  * Returns silently on failure — never blocks navigation.
2154
2552
  */
2155
2553
  async dismissCookieBanner(targetWsUrl) {
2554
+ const notes = /* @__PURE__ */ new Set();
2156
2555
  for (let attempt = 0; attempt < 2; attempt++) {
2157
2556
  try {
2158
2557
  const domResult = await this.dismissViaDom(targetWsUrl);
2159
2558
  if (domResult.dismissed) return domResult;
2160
- } catch {}
2559
+ } catch (error) {
2560
+ notes.add(`dom: ${error.message}`);
2561
+ }
2161
2562
  try {
2162
2563
  const iframeResult = await this.dismissViaIframe(targetWsUrl);
2163
2564
  if (iframeResult.dismissed) return iframeResult;
2164
- } catch {}
2565
+ if (iframeResult.detail) notes.add(iframeResult.detail);
2566
+ } catch (error) {
2567
+ notes.add(`iframe: ${error.message}`);
2568
+ }
2165
2569
  try {
2166
2570
  const a11yResult = await this.dismissViaA11y(targetWsUrl);
2167
2571
  if (a11yResult.dismissed) return a11yResult;
2168
- } catch {}
2572
+ } catch (error) {
2573
+ notes.add(`a11y: ${error.message}`);
2574
+ }
2169
2575
  if (attempt === 0) await new Promise((resolve) => setTimeout(resolve, 800));
2170
2576
  }
2171
- return { dismissed: false };
2577
+ return {
2578
+ dismissed: false,
2579
+ detail: notes.size ? [...notes].join("; ") : "no consent banner or accept button found (page, consent iframes, a11y tree)"
2580
+ };
2172
2581
  }
2173
2582
  async dismissViaDom(targetWsUrl) {
2174
2583
  const expression = `(() => {
@@ -2423,7 +2832,42 @@ var ChromeCdpBrowserController = class {
2423
2832
  * These banners render inside an iframe that needs to be accessed separately.
2424
2833
  */
2425
2834
  async dismissViaIframe(targetWsUrl) {
2835
+ const innerSearch = `function (doc) {
2836
+ const innerSelectors = [
2837
+ // Sourcepoint specific
2838
+ 'button.sp_choice_type_11', // Accept all
2839
+ 'button.sp_choice_type_ACCEPT_ALL', // Accept all variant
2840
+ 'button.sp_choice_type_SAVE_AND_EXIT', // Save & exit
2841
+ '.priv-save-btn', // Sourcepoint frame
2842
+ '.message-button', // Generic Sourcepoint
2843
+ // Title-based
2844
+ 'button[title*="Accept"]', 'button[title*="accept"]',
2845
+ 'button[title*="Agree"]', 'button[title*="agree"]',
2846
+ 'button[title*="Allow"]',
2847
+ 'button[title*="Akzeptieren"]', 'button[title*="akzeptieren"]',
2848
+ 'button[title*="Zustimmen"]',
2849
+ ];
2850
+ for (const innerSel of innerSelectors) {
2851
+ const btn = doc.querySelector(innerSel);
2852
+ if (btn && btn.offsetWidth > 0) {
2853
+ btn.click();
2854
+ return { method: 'iframe', detail: innerSel };
2855
+ }
2856
+ }
2857
+ // Text-based fallback inside the iframe
2858
+ const buttons = doc.querySelectorAll('button, [role="button"]');
2859
+ for (const btn of buttons) {
2860
+ const text = (btn.textContent || '').trim();
2861
+ if (text.length > 60) continue;
2862
+ if (/accept|agree|allow|akzeptieren|zustimmen|einwilligen|accepter|aceptar/i.test(text)) {
2863
+ btn.click();
2864
+ return { method: 'iframe-text', detail: text };
2865
+ }
2866
+ }
2867
+ return null;
2868
+ }`;
2426
2869
  const expression = `(() => {
2870
+ const clickAccept = ${innerSearch};
2427
2871
  // Known consent iframe selectors
2428
2872
  const iframeSelectors = [
2429
2873
  'iframe[id^="sp_message_iframe"]', // Sourcepoint (dynamic ID suffix)
@@ -2444,62 +2888,69 @@ var ChromeCdpBrowserController = class {
2444
2888
  'iframe[src*="consentmanager"]',
2445
2889
  ];
2446
2890
 
2891
+ // Cross-origin consent frames are handed back so the caller can attach to them.
2892
+ const crossOrigin = [];
2447
2893
  for (const sel of iframeSelectors) {
2894
+ const iframe = document.querySelector(sel);
2895
+ if (!iframe) continue;
2896
+ let doc = null;
2448
2897
  try {
2449
- const iframe = document.querySelector(sel);
2450
- if (!iframe) continue;
2451
- const doc = iframe.contentDocument || iframe.contentWindow?.document;
2452
- if (!doc) continue;
2453
-
2454
- // Try known selectors inside the iframe
2455
- const innerSelectors = [
2456
- // Sourcepoint specific
2457
- 'button.sp_choice_type_11', // Accept all
2458
- 'button.sp_choice_type_ACCEPT_ALL', // Accept all variant
2459
- 'button.sp_choice_type_SAVE_AND_EXIT', // Save & exit
2460
- '.priv-save-btn', // Sourcepoint frame
2461
- '.message-button', // Generic Sourcepoint
2462
- // Title-based
2463
- 'button[title*="Accept"]', 'button[title*="accept"]',
2464
- 'button[title*="Agree"]', 'button[title*="agree"]',
2465
- 'button[title*="Allow"]',
2466
- 'button[title*="Akzeptieren"]', 'button[title*="akzeptieren"]',
2467
- 'button[title*="Zustimmen"]',
2468
- ];
2469
- for (const innerSel of innerSelectors) {
2470
- const btn = doc.querySelector(innerSel);
2471
- if (btn && btn.offsetWidth > 0) {
2472
- btn.click();
2473
- return JSON.stringify({ dismissed: true, method: 'iframe', detail: sel + ' > ' + innerSel });
2474
- }
2475
- }
2476
-
2477
- // Text-based fallback inside iframe
2478
- const buttons = doc.querySelectorAll('button, [role="button"]');
2479
- for (const btn of buttons) {
2480
- const text = (btn.textContent || '').trim();
2481
- if (text.length > 60) continue;
2482
- if (/accept|agree|allow|akzeptieren|zustimmen|einwilligen|accepter|aceptar/i.test(text)) {
2483
- btn.click();
2484
- return JSON.stringify({ dismissed: true, method: 'iframe-text', detail: text });
2485
- }
2486
- }
2487
- } catch { /* cross-origin iframe — skip */ }
2898
+ doc = iframe.contentDocument || iframe.contentWindow?.document || null;
2899
+ } catch {
2900
+ doc = null;
2901
+ }
2902
+ if (!doc) {
2903
+ const locator = iframe.id ? '#' + CSS.escape(iframe.id) : sel;
2904
+ if (!crossOrigin.includes(locator)) crossOrigin.push(locator);
2905
+ continue;
2906
+ }
2907
+ const hit = clickAccept(doc);
2908
+ if (hit) return { dismissed: true, method: hit.method, detail: sel + ' > ' + hit.detail };
2488
2909
  }
2489
-
2490
- return JSON.stringify({ dismissed: false });
2910
+ return { dismissed: false, crossOrigin };
2491
2911
  })()`;
2492
2912
  return await this.withRetry(targetWsUrl, async (conn) => {
2493
2913
  await this.ensureEnabled(targetWsUrl);
2494
- const raw = (await evaluateOnPage(conn, {
2914
+ const value = (await evaluateOnPage(conn, {
2495
2915
  expression,
2496
2916
  returnByValue: true
2497
- })).result.value ?? "{\"dismissed\":false}";
2498
- try {
2499
- return JSON.parse(raw);
2500
- } catch {
2501
- return { dismissed: false };
2917
+ })).result.value;
2918
+ if (!value) return { dismissed: false };
2919
+ if (value.dismissed) return {
2920
+ dismissed: true,
2921
+ method: value.method,
2922
+ detail: value.detail
2923
+ };
2924
+ const notes = [];
2925
+ for (const frame of value.crossOrigin ?? []) {
2926
+ let scope;
2927
+ try {
2928
+ scope = await this.openFrameScope(conn, targetWsUrl, frame);
2929
+ } catch (error) {
2930
+ notes.push(`consent iframe ${frame}: ${error.message}`);
2931
+ continue;
2932
+ }
2933
+ try {
2934
+ const hit = (await evaluateOnPage(scope.client, {
2935
+ expression: `(${innerSearch})(document)`,
2936
+ returnByValue: true
2937
+ })).result.value;
2938
+ if (hit) return {
2939
+ dismissed: true,
2940
+ method: hit.method,
2941
+ detail: `${frame} >>> ${hit.detail}`
2942
+ };
2943
+ notes.push(`consent iframe ${frame}: no accept button found`);
2944
+ } catch (error) {
2945
+ notes.push(`consent iframe ${frame}: ${error.message}`);
2946
+ } finally {
2947
+ scope.release();
2948
+ }
2502
2949
  }
2950
+ return notes.length ? {
2951
+ dismissed: false,
2952
+ detail: notes.join("; ")
2953
+ } : { dismissed: false };
2503
2954
  });
2504
2955
  }
2505
2956
  /**
@@ -2696,6 +3147,64 @@ var MockBrowserController = class {
2696
3147
  async getCurrentUrl(cdpUrl) {
2697
3148
  return this.pages.get(cdpUrl)?.url ?? "about:blank";
2698
3149
  }
3150
+ tabs = /* @__PURE__ */ new Map();
3151
+ tabCounter = 0;
3152
+ /** Tab ids are the page keys (targetWsUrl); the initial page is the cdpUrl itself. */
3153
+ tabIds(cdpUrl) {
3154
+ let ids = this.tabs.get(cdpUrl);
3155
+ if (!ids) {
3156
+ ids = this.pages.has(cdpUrl) ? [cdpUrl] : [];
3157
+ this.tabs.set(cdpUrl, ids);
3158
+ }
3159
+ return ids;
3160
+ }
3161
+ async getPageState(targetWsUrl) {
3162
+ const page = this.pages.get(targetWsUrl);
3163
+ if (!page) throw new Error("mock page missing");
3164
+ return {
3165
+ url: page.url,
3166
+ title: page.title,
3167
+ readyState: "complete"
3168
+ };
3169
+ }
3170
+ async listTabs(cdpUrl) {
3171
+ return this.tabIds(cdpUrl).map((id) => {
3172
+ const page = this.pages.get(id);
3173
+ return {
3174
+ id,
3175
+ url: page?.url ?? "about:blank",
3176
+ title: page?.title ?? "",
3177
+ targetWsUrl: id
3178
+ };
3179
+ });
3180
+ }
3181
+ async openTab(cdpUrl) {
3182
+ this.tabCounter += 1;
3183
+ const id = `${cdpUrl}/tab-${this.tabCounter}`;
3184
+ this.pages.set(id, {
3185
+ url: "about:blank",
3186
+ title: "about:blank",
3187
+ text: "",
3188
+ html: "<html><body></body></html>"
3189
+ });
3190
+ this.tabIds(cdpUrl).push(id);
3191
+ return {
3192
+ id,
3193
+ url: "about:blank",
3194
+ title: "about:blank",
3195
+ targetWsUrl: id
3196
+ };
3197
+ }
3198
+ async activateTab(cdpUrl, tabId) {
3199
+ if (!this.tabIds(cdpUrl).includes(tabId)) throw new Error(`Tab not found: ${tabId}`);
3200
+ }
3201
+ async closeTab(cdpUrl, tabId) {
3202
+ const ids = this.tabIds(cdpUrl);
3203
+ const index = ids.indexOf(tabId);
3204
+ if (index === -1) throw new Error(`Tab not found: ${tabId}`);
3205
+ ids.splice(index, 1);
3206
+ this.pages.delete(tabId);
3207
+ }
2699
3208
  async getContent(cdpUrl, options) {
2700
3209
  const page = this.pages.get(cdpUrl);
2701
3210
  if (!page) throw new Error("mock page missing");
@@ -2762,7 +3271,8 @@ const InteractPayloadSchema = z.object({
2762
3271
  value: z.string().optional(),
2763
3272
  scrollX: z.number().optional(),
2764
3273
  scrollY: z.number().optional(),
2765
- timeoutMs: z.number().optional()
3274
+ timeoutMs: z.number().optional(),
3275
+ frame: z.string().optional()
2766
3276
  });
2767
3277
  const SessionStatusSchema = z.enum([
2768
3278
  "starting",
@@ -3153,6 +3663,68 @@ var SessionManager = class {
3153
3663
  const record = await this.ensureSession(sessionId);
3154
3664
  return await this.browser.getCurrentUrl(record.targetWsUrl);
3155
3665
  }
3666
+ async getPageState(sessionId) {
3667
+ const record = await this.ensureSession(sessionId);
3668
+ return await this.browser.getPageState(record.targetWsUrl);
3669
+ }
3670
+ async listTabs(sessionId) {
3671
+ const record = await this.ensureSession(sessionId);
3672
+ return (await this.browser.listTabs(record.cdpUrl)).map((tab) => ({
3673
+ ...tab,
3674
+ active: tab.targetWsUrl === record.targetWsUrl
3675
+ }));
3676
+ }
3677
+ /** Points the session at another tab; later commands run there. */
3678
+ async switchTab(sessionId, tabId) {
3679
+ const record = await this.ensureSession(sessionId);
3680
+ const tab = (await this.browser.listTabs(record.cdpUrl)).find((t) => t.id === tabId);
3681
+ if (!tab) throw new Error(`Tab not found: ${tabId}`);
3682
+ await this.browser.activateTab(record.cdpUrl, tab.id);
3683
+ this.store.save({
3684
+ ...record,
3685
+ targetWsUrl: tab.targetWsUrl,
3686
+ lastUrl: tab.url || record.lastUrl
3687
+ });
3688
+ this.recordEvent(sessionId, "lifecycle", "info", `Switched to tab ${tab.id}`);
3689
+ return tab;
3690
+ }
3691
+ async newTab(sessionId) {
3692
+ const record = await this.ensureSession(sessionId);
3693
+ const tab = await this.browser.openTab(record.cdpUrl);
3694
+ await this.browser.activateTab(record.cdpUrl, tab.id);
3695
+ this.store.save({
3696
+ ...record,
3697
+ targetWsUrl: tab.targetWsUrl,
3698
+ lastUrl: tab.url
3699
+ });
3700
+ this.recordEvent(sessionId, "lifecycle", "info", `Opened tab ${tab.id}`);
3701
+ return tab;
3702
+ }
3703
+ /** Closes a tab; closing the active one moves the session to a remaining tab. */
3704
+ async closeTab(sessionId, tabId) {
3705
+ const record = await this.ensureSession(sessionId);
3706
+ const tabs = await this.browser.listTabs(record.cdpUrl);
3707
+ const target = tabs.find((t) => t.id === tabId);
3708
+ if (!target) throw new Error(`Tab not found: ${tabId}`);
3709
+ const remaining = tabs.filter((t) => t.id !== tabId);
3710
+ if (remaining.length === 0) throw new Error("Refusing to close the last tab; use agent stop to end the session");
3711
+ await this.browser.closeTab(record.cdpUrl, tabId);
3712
+ let active = tabs.find((t) => t.targetWsUrl === record.targetWsUrl) ?? remaining[0];
3713
+ if (target.targetWsUrl === record.targetWsUrl) {
3714
+ active = remaining[remaining.length - 1];
3715
+ await this.browser.activateTab(record.cdpUrl, active.id);
3716
+ this.store.save({
3717
+ ...record,
3718
+ targetWsUrl: active.targetWsUrl,
3719
+ lastUrl: active.url
3720
+ });
3721
+ }
3722
+ this.recordEvent(sessionId, "lifecycle", "info", `Closed tab ${tabId}`);
3723
+ return {
3724
+ closed: tabId,
3725
+ active
3726
+ };
3727
+ }
3156
3728
  async dismissCookieBanner(sessionId) {
3157
3729
  const record = await this.ensureSession(sessionId);
3158
3730
  const result = await this.browser.dismissCookieBanner(record.targetWsUrl);
@@ -3437,6 +4009,21 @@ var ControlApi = class {
3437
4009
  async getCurrentUrl(sessionId) {
3438
4010
  return await this.sessions.getCurrentUrl(sessionId);
3439
4011
  }
4012
+ async getPageState(sessionId) {
4013
+ return await this.sessions.getPageState(sessionId);
4014
+ }
4015
+ async listTabs(sessionId) {
4016
+ return await this.sessions.listTabs(sessionId);
4017
+ }
4018
+ async switchTab(sessionId, tabId) {
4019
+ return await this.sessions.switchTab(sessionId, tabId);
4020
+ }
4021
+ async newTab(sessionId) {
4022
+ return await this.sessions.newTab(sessionId);
4023
+ }
4024
+ async closeTab(sessionId, tabId) {
4025
+ return await this.sessions.closeTab(sessionId, tabId);
4026
+ }
3440
4027
  listEvents(sessionId, limit = 100) {
3441
4028
  return { events: this.sessions.listEvents(sessionId, limit) };
3442
4029
  }
@@ -4580,7 +5167,10 @@ var AgenticBrowserCore = class {
4580
5167
  roles: input.roles,
4581
5168
  visibleOnly: input.visibleOnly,
4582
5169
  limit: input.limit,
4583
- selector: input.selector
5170
+ selector: input.selector,
5171
+ href: input.href,
5172
+ text: input.text,
5173
+ frame: input.frame
4584
5174
  });
4585
5175
  }
4586
5176
  async dismissCookieBanner(sessionId) {
@@ -4592,6 +5182,21 @@ var AgenticBrowserCore = class {
4592
5182
  async getCurrentUrl(sessionId) {
4593
5183
  return await this.api.getCurrentUrl(sessionId);
4594
5184
  }
5185
+ async getPageState(sessionId) {
5186
+ return await this.api.getPageState(sessionId);
5187
+ }
5188
+ async listTabs(sessionId) {
5189
+ return await this.api.listTabs(sessionId);
5190
+ }
5191
+ async switchTab(sessionId, tabId) {
5192
+ return await this.api.switchTab(sessionId, tabId);
5193
+ }
5194
+ async newTab(sessionId) {
5195
+ return await this.api.newTab(sessionId);
5196
+ }
5197
+ async closeTab(sessionId, tabId) {
5198
+ return await this.api.closeTab(sessionId, tabId);
5199
+ }
4595
5200
  async restartSession(sessionId) {
4596
5201
  return await this.api.restartSession(sessionId);
4597
5202
  }