agentic-browser 2.6.2 → 2.7.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.
@@ -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();
@@ -1319,7 +1597,7 @@ var ChromeCdpBrowserController = class {
1319
1597
  if (result && typeof result === 'object' && typeof result.then === 'function') {
1320
1598
  result = await result;
1321
1599
  }
1322
- return typeof result === 'string' ? result : JSON.stringify(result);
1600
+ return result === undefined ? 'undefined' : JSON.stringify(result);
1323
1601
  }
1324
1602
  if (payload.action === 'scroll') {
1325
1603
  if (payload.selector) {
@@ -1348,11 +1626,18 @@ 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
+ // Always JSON-encode so strings like "42" or "" survive the round trip as strings.
1635
+ return __agenticResult === undefined ? 'undefined' : JSON.stringify(__agenticResult);
1636
+ })()` : buildExpression(scoped),
1637
+ returnByValue: true,
1638
+ awaitPromise: true
1639
+ })).result.value ?? "";
1640
+ });
1356
1641
  }, "connection");
1357
1642
  }
1358
1643
  async screenshot(targetWsUrl, options) {
@@ -1388,6 +1673,66 @@ var ChromeCdpBrowserController = class {
1388
1673
  })).result.value ?? "";
1389
1674
  });
1390
1675
  }
1676
+ async getPageState(targetWsUrl) {
1677
+ return await this.withRetry(targetWsUrl, async (conn) => {
1678
+ return (await evaluateOnPage(conn, {
1679
+ expression: "({ url: location.href, title: document.title, readyState: document.readyState })",
1680
+ returnByValue: true
1681
+ })).result.value ?? {
1682
+ url: "",
1683
+ title: "",
1684
+ readyState: "loading"
1685
+ };
1686
+ });
1687
+ }
1688
+ async listTabs(cdpUrl) {
1689
+ return (await getJson(`${cdpUrl}/json/list`)).filter((target) => target.type === "page" && target.webSocketDebuggerUrl).map((target) => ({
1690
+ id: target.id,
1691
+ url: target.url ?? "",
1692
+ title: target.title ?? "",
1693
+ targetWsUrl: target.webSocketDebuggerUrl
1694
+ }));
1695
+ }
1696
+ async openTab(cdpUrl) {
1697
+ const endpoint = `${cdpUrl}/json/new?${encodeURIComponent("about:blank")}`;
1698
+ let created;
1699
+ for (const method of ["PUT", "GET"]) try {
1700
+ const response = await fetch(endpoint, {
1701
+ method,
1702
+ signal: AbortSignal.timeout(5e3)
1703
+ });
1704
+ if (!response.ok) continue;
1705
+ created = await response.json();
1706
+ if (created.webSocketDebuggerUrl) break;
1707
+ } catch {}
1708
+ if (!created?.webSocketDebuggerUrl) throw new Error(`Unable to open a new tab via ${cdpUrl}/json/new`);
1709
+ const targetWsUrl = created.webSocketDebuggerUrl;
1710
+ const host = new URL(cdpUrl).host;
1711
+ let userAgent;
1712
+ for (const [wsUrl, ua] of this.userAgentOverrides) if (new URL(wsUrl).host === host) {
1713
+ userAgent = ua;
1714
+ break;
1715
+ }
1716
+ const appliedUa = await initTarget(targetWsUrl, userAgent);
1717
+ if (appliedUa) this.userAgentOverrides.set(targetWsUrl, appliedUa);
1718
+ return {
1719
+ id: created.id,
1720
+ url: created.url ?? "about:blank",
1721
+ title: created.title ?? "",
1722
+ targetWsUrl
1723
+ };
1724
+ }
1725
+ async activateTab(cdpUrl, tabId) {
1726
+ if (!(await fetch(`${cdpUrl}/json/activate/${encodeURIComponent(tabId)}`, { signal: AbortSignal.timeout(5e3) })).ok) throw new Error(`Tab not found: ${tabId}`);
1727
+ }
1728
+ async closeTab(cdpUrl, tabId) {
1729
+ const tab = (await this.listTabs(cdpUrl)).find((candidate) => candidate.id === tabId);
1730
+ if (!tab) throw new Error(`Tab not found: ${tabId}`);
1731
+ this.dropConnection(tab.targetWsUrl);
1732
+ this.userAgentOverrides.delete(tab.targetWsUrl);
1733
+ const response = await fetch(`${cdpUrl}/json/close/${encodeURIComponent(tabId)}`, { signal: AbortSignal.timeout(5e3) });
1734
+ if (!response.ok) throw new Error(`Unable to close tab ${tabId}: HTTP ${response.status}`);
1735
+ }
1391
1736
  async getContent(targetWsUrl, options) {
1392
1737
  if (options.mode === "summary") return await this.getSummaryContent(targetWsUrl, options);
1393
1738
  if (options.mode === "a11y") {
@@ -1773,6 +2118,10 @@ var ChromeCdpBrowserController = class {
1773
2118
  const limit = options.limit ?? 50;
1774
2119
  const scopeSelector = options.selector;
1775
2120
  const roleFilter = options.roles ? new Set(options.roles) : null;
2121
+ const hrefFilter = options.href ? String(options.href).toLowerCase() : '';
2122
+ const textFilter = options.text
2123
+ ? String(options.text).replace(/\\s+/g, ' ').trim().toLowerCase()
2124
+ : '';
1776
2125
  const candidateSelector = [
1777
2126
  'a[href]',
1778
2127
  'button',
@@ -2044,6 +2393,7 @@ var ChromeCdpBrowserController = class {
2044
2393
  }
2045
2394
 
2046
2395
  const results = [];
2396
+ const crossOriginFrames = [];
2047
2397
  let totalFound = 0;
2048
2398
 
2049
2399
  function maybeRecordElement(root, path, el) {
@@ -2061,6 +2411,25 @@ var ChromeCdpBrowserController = class {
2061
2411
  const visible = isVisible(el);
2062
2412
  if (visibleOnly && !visible) return;
2063
2413
 
2414
+ if (hrefFilter) {
2415
+ const href = typeof el.href === 'string' ? el.href : el.getAttribute('href');
2416
+ if (!href || !String(href).toLowerCase().includes(hrefFilter)) return;
2417
+ }
2418
+ if (textFilter) {
2419
+ const haystack = [
2420
+ el.innerText || el.textContent || '',
2421
+ el.getAttribute('aria-label'),
2422
+ el.getAttribute('placeholder'),
2423
+ el.getAttribute('title'),
2424
+ typeof el.value === 'string' ? el.value : '',
2425
+ ]
2426
+ .filter(Boolean)
2427
+ .join(' ')
2428
+ .replace(/\\s+/g, ' ')
2429
+ .toLowerCase();
2430
+ if (!haystack.includes(textFilter)) return;
2431
+ }
2432
+
2064
2433
  totalFound += 1;
2065
2434
  if (results.length >= limit) return;
2066
2435
 
@@ -2104,12 +2473,21 @@ var ChromeCdpBrowserController = class {
2104
2473
  }
2105
2474
 
2106
2475
  if (current.tagName && current.tagName.toLowerCase() === 'iframe') {
2476
+ let frameDocument = null;
2107
2477
  try {
2108
- if (current.contentDocument) {
2109
- walkRoot(current.contentDocument, path.concat(buildSelector(root, current)));
2110
- }
2478
+ frameDocument = current.contentDocument;
2111
2479
  } catch {
2112
- // ignore inaccessible frames
2480
+ frameDocument = null;
2481
+ }
2482
+ if (frameDocument) {
2483
+ walkRoot(frameDocument, path.concat(buildSelector(root, current)));
2484
+ } else if (crossOriginFrames.length < 20 && isVisible(current)) {
2485
+ // Cross-origin: not walkable from here, report it so callers can pass it as frame.
2486
+ const info = { selector: composeLocator(path, buildSelector(root, current)) };
2487
+ const title = current.getAttribute('title');
2488
+ if (title) info.title = title.slice(0, 80);
2489
+ if (current.src) info.src = String(current.src).slice(0, 200);
2490
+ crossOriginFrames.push(info);
2113
2491
  }
2114
2492
  }
2115
2493
 
@@ -2120,7 +2498,9 @@ var ChromeCdpBrowserController = class {
2120
2498
  }
2121
2499
 
2122
2500
  walkRoot(document, []);
2123
- return { elements: results, totalFound, truncated: totalFound > results.length };
2501
+ const output = { elements: results, totalFound, truncated: totalFound > results.length };
2502
+ if (crossOriginFrames.length) output.crossOriginFrames = crossOriginFrames;
2503
+ return output;
2124
2504
  `);
2125
2505
  const emptyResult = {
2126
2506
  elements: [],
@@ -2134,12 +2514,31 @@ var ChromeCdpBrowserController = class {
2134
2514
  };
2135
2515
  return await this.withRetry(targetWsUrl, async (conn) => {
2136
2516
  await this.ensureEnabled(targetWsUrl);
2137
- const result = await evaluateOnPage(conn, {
2517
+ const evaluate = async (client) => extract(await evaluateOnPage(client, {
2138
2518
  expression,
2139
2519
  returnByValue: true,
2140
2520
  awaitPromise: true
2141
- });
2142
- return extract(result);
2521
+ }));
2522
+ if (!options.frame) return await evaluate(conn);
2523
+ const scope = await this.openFrameScope(conn, targetWsUrl, options.frame);
2524
+ try {
2525
+ const inner = await evaluate(scope.client);
2526
+ const withPrefix = (selector) => `${options.frame} >>> ${selector}`;
2527
+ return {
2528
+ ...inner,
2529
+ elements: inner.elements.map((element) => ({
2530
+ ...element,
2531
+ selector: withPrefix(element.selector),
2532
+ fallbackSelectors: element.fallbackSelectors?.map(withPrefix)
2533
+ })),
2534
+ crossOriginFrames: inner.crossOriginFrames?.map((frame) => ({
2535
+ ...frame,
2536
+ selector: withPrefix(frame.selector)
2537
+ }))
2538
+ };
2539
+ } finally {
2540
+ scope.release();
2541
+ }
2143
2542
  });
2144
2543
  }
2145
2544
  /**
@@ -2153,22 +2552,33 @@ var ChromeCdpBrowserController = class {
2153
2552
  * Returns silently on failure — never blocks navigation.
2154
2553
  */
2155
2554
  async dismissCookieBanner(targetWsUrl) {
2555
+ const notes = /* @__PURE__ */ new Set();
2156
2556
  for (let attempt = 0; attempt < 2; attempt++) {
2157
2557
  try {
2158
2558
  const domResult = await this.dismissViaDom(targetWsUrl);
2159
2559
  if (domResult.dismissed) return domResult;
2160
- } catch {}
2560
+ } catch (error) {
2561
+ notes.add(`dom: ${error.message}`);
2562
+ }
2161
2563
  try {
2162
2564
  const iframeResult = await this.dismissViaIframe(targetWsUrl);
2163
2565
  if (iframeResult.dismissed) return iframeResult;
2164
- } catch {}
2566
+ if (iframeResult.detail) notes.add(iframeResult.detail);
2567
+ } catch (error) {
2568
+ notes.add(`iframe: ${error.message}`);
2569
+ }
2165
2570
  try {
2166
2571
  const a11yResult = await this.dismissViaA11y(targetWsUrl);
2167
2572
  if (a11yResult.dismissed) return a11yResult;
2168
- } catch {}
2573
+ } catch (error) {
2574
+ notes.add(`a11y: ${error.message}`);
2575
+ }
2169
2576
  if (attempt === 0) await new Promise((resolve) => setTimeout(resolve, 800));
2170
2577
  }
2171
- return { dismissed: false };
2578
+ return {
2579
+ dismissed: false,
2580
+ detail: notes.size ? [...notes].join("; ") : "no consent banner or accept button found (page, consent iframes, a11y tree)"
2581
+ };
2172
2582
  }
2173
2583
  async dismissViaDom(targetWsUrl) {
2174
2584
  const expression = `(() => {
@@ -2423,7 +2833,42 @@ var ChromeCdpBrowserController = class {
2423
2833
  * These banners render inside an iframe that needs to be accessed separately.
2424
2834
  */
2425
2835
  async dismissViaIframe(targetWsUrl) {
2836
+ const innerSearch = `function (doc) {
2837
+ const innerSelectors = [
2838
+ // Sourcepoint specific
2839
+ 'button.sp_choice_type_11', // Accept all
2840
+ 'button.sp_choice_type_ACCEPT_ALL', // Accept all variant
2841
+ 'button.sp_choice_type_SAVE_AND_EXIT', // Save & exit
2842
+ '.priv-save-btn', // Sourcepoint frame
2843
+ '.message-button', // Generic Sourcepoint
2844
+ // Title-based
2845
+ 'button[title*="Accept"]', 'button[title*="accept"]',
2846
+ 'button[title*="Agree"]', 'button[title*="agree"]',
2847
+ 'button[title*="Allow"]',
2848
+ 'button[title*="Akzeptieren"]', 'button[title*="akzeptieren"]',
2849
+ 'button[title*="Zustimmen"]',
2850
+ ];
2851
+ for (const innerSel of innerSelectors) {
2852
+ const btn = doc.querySelector(innerSel);
2853
+ if (btn && btn.offsetWidth > 0) {
2854
+ btn.click();
2855
+ return { method: 'iframe', detail: innerSel };
2856
+ }
2857
+ }
2858
+ // Text-based fallback inside the iframe
2859
+ const buttons = doc.querySelectorAll('button, [role="button"]');
2860
+ for (const btn of buttons) {
2861
+ const text = (btn.textContent || '').trim();
2862
+ if (text.length > 60) continue;
2863
+ if (/accept|agree|allow|akzeptieren|zustimmen|einwilligen|accepter|aceptar/i.test(text)) {
2864
+ btn.click();
2865
+ return { method: 'iframe-text', detail: text };
2866
+ }
2867
+ }
2868
+ return null;
2869
+ }`;
2426
2870
  const expression = `(() => {
2871
+ const clickAccept = ${innerSearch};
2427
2872
  // Known consent iframe selectors
2428
2873
  const iframeSelectors = [
2429
2874
  'iframe[id^="sp_message_iframe"]', // Sourcepoint (dynamic ID suffix)
@@ -2444,62 +2889,69 @@ var ChromeCdpBrowserController = class {
2444
2889
  'iframe[src*="consentmanager"]',
2445
2890
  ];
2446
2891
 
2892
+ // Cross-origin consent frames are handed back so the caller can attach to them.
2893
+ const crossOrigin = [];
2447
2894
  for (const sel of iframeSelectors) {
2895
+ const iframe = document.querySelector(sel);
2896
+ if (!iframe) continue;
2897
+ let doc = null;
2448
2898
  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 */ }
2899
+ doc = iframe.contentDocument || iframe.contentWindow?.document || null;
2900
+ } catch {
2901
+ doc = null;
2902
+ }
2903
+ if (!doc) {
2904
+ const locator = iframe.id ? '#' + CSS.escape(iframe.id) : sel;
2905
+ if (!crossOrigin.includes(locator)) crossOrigin.push(locator);
2906
+ continue;
2907
+ }
2908
+ const hit = clickAccept(doc);
2909
+ if (hit) return { dismissed: true, method: hit.method, detail: sel + ' > ' + hit.detail };
2488
2910
  }
2489
-
2490
- return JSON.stringify({ dismissed: false });
2911
+ return { dismissed: false, crossOrigin };
2491
2912
  })()`;
2492
2913
  return await this.withRetry(targetWsUrl, async (conn) => {
2493
2914
  await this.ensureEnabled(targetWsUrl);
2494
- const raw = (await evaluateOnPage(conn, {
2915
+ const value = (await evaluateOnPage(conn, {
2495
2916
  expression,
2496
2917
  returnByValue: true
2497
- })).result.value ?? "{\"dismissed\":false}";
2498
- try {
2499
- return JSON.parse(raw);
2500
- } catch {
2501
- return { dismissed: false };
2918
+ })).result.value;
2919
+ if (!value) return { dismissed: false };
2920
+ if (value.dismissed) return {
2921
+ dismissed: true,
2922
+ method: value.method,
2923
+ detail: value.detail
2924
+ };
2925
+ const notes = [];
2926
+ for (const frame of value.crossOrigin ?? []) {
2927
+ let scope;
2928
+ try {
2929
+ scope = await this.openFrameScope(conn, targetWsUrl, frame);
2930
+ } catch (error) {
2931
+ notes.push(`consent iframe ${frame}: ${error.message}`);
2932
+ continue;
2933
+ }
2934
+ try {
2935
+ const hit = (await evaluateOnPage(scope.client, {
2936
+ expression: `(${innerSearch})(document)`,
2937
+ returnByValue: true
2938
+ })).result.value;
2939
+ if (hit) return {
2940
+ dismissed: true,
2941
+ method: hit.method,
2942
+ detail: `${frame} >>> ${hit.detail}`
2943
+ };
2944
+ notes.push(`consent iframe ${frame}: no accept button found`);
2945
+ } catch (error) {
2946
+ notes.push(`consent iframe ${frame}: ${error.message}`);
2947
+ } finally {
2948
+ scope.release();
2949
+ }
2502
2950
  }
2951
+ return notes.length ? {
2952
+ dismissed: false,
2953
+ detail: notes.join("; ")
2954
+ } : { dismissed: false };
2503
2955
  });
2504
2956
  }
2505
2957
  /**
@@ -2696,6 +3148,64 @@ var MockBrowserController = class {
2696
3148
  async getCurrentUrl(cdpUrl) {
2697
3149
  return this.pages.get(cdpUrl)?.url ?? "about:blank";
2698
3150
  }
3151
+ tabs = /* @__PURE__ */ new Map();
3152
+ tabCounter = 0;
3153
+ /** Tab ids are the page keys (targetWsUrl); the initial page is the cdpUrl itself. */
3154
+ tabIds(cdpUrl) {
3155
+ let ids = this.tabs.get(cdpUrl);
3156
+ if (!ids) {
3157
+ ids = this.pages.has(cdpUrl) ? [cdpUrl] : [];
3158
+ this.tabs.set(cdpUrl, ids);
3159
+ }
3160
+ return ids;
3161
+ }
3162
+ async getPageState(targetWsUrl) {
3163
+ const page = this.pages.get(targetWsUrl);
3164
+ if (!page) throw new Error("mock page missing");
3165
+ return {
3166
+ url: page.url,
3167
+ title: page.title,
3168
+ readyState: "complete"
3169
+ };
3170
+ }
3171
+ async listTabs(cdpUrl) {
3172
+ return this.tabIds(cdpUrl).map((id) => {
3173
+ const page = this.pages.get(id);
3174
+ return {
3175
+ id,
3176
+ url: page?.url ?? "about:blank",
3177
+ title: page?.title ?? "",
3178
+ targetWsUrl: id
3179
+ };
3180
+ });
3181
+ }
3182
+ async openTab(cdpUrl) {
3183
+ this.tabCounter += 1;
3184
+ const id = `${cdpUrl}/tab-${this.tabCounter}`;
3185
+ this.pages.set(id, {
3186
+ url: "about:blank",
3187
+ title: "about:blank",
3188
+ text: "",
3189
+ html: "<html><body></body></html>"
3190
+ });
3191
+ this.tabIds(cdpUrl).push(id);
3192
+ return {
3193
+ id,
3194
+ url: "about:blank",
3195
+ title: "about:blank",
3196
+ targetWsUrl: id
3197
+ };
3198
+ }
3199
+ async activateTab(cdpUrl, tabId) {
3200
+ if (!this.tabIds(cdpUrl).includes(tabId)) throw new Error(`Tab not found: ${tabId}`);
3201
+ }
3202
+ async closeTab(cdpUrl, tabId) {
3203
+ const ids = this.tabIds(cdpUrl);
3204
+ const index = ids.indexOf(tabId);
3205
+ if (index === -1) throw new Error(`Tab not found: ${tabId}`);
3206
+ ids.splice(index, 1);
3207
+ this.pages.delete(tabId);
3208
+ }
2699
3209
  async getContent(cdpUrl, options) {
2700
3210
  const page = this.pages.get(cdpUrl);
2701
3211
  if (!page) throw new Error("mock page missing");
@@ -2762,7 +3272,8 @@ const InteractPayloadSchema = z.object({
2762
3272
  value: z.string().optional(),
2763
3273
  scrollX: z.number().optional(),
2764
3274
  scrollY: z.number().optional(),
2765
- timeoutMs: z.number().optional()
3275
+ timeoutMs: z.number().optional(),
3276
+ frame: z.string().optional()
2766
3277
  });
2767
3278
  const SessionStatusSchema = z.enum([
2768
3279
  "starting",
@@ -3153,6 +3664,68 @@ var SessionManager = class {
3153
3664
  const record = await this.ensureSession(sessionId);
3154
3665
  return await this.browser.getCurrentUrl(record.targetWsUrl);
3155
3666
  }
3667
+ async getPageState(sessionId) {
3668
+ const record = await this.ensureSession(sessionId);
3669
+ return await this.browser.getPageState(record.targetWsUrl);
3670
+ }
3671
+ async listTabs(sessionId) {
3672
+ const record = await this.ensureSession(sessionId);
3673
+ return (await this.browser.listTabs(record.cdpUrl)).map((tab) => ({
3674
+ ...tab,
3675
+ active: tab.targetWsUrl === record.targetWsUrl
3676
+ }));
3677
+ }
3678
+ /** Points the session at another tab; later commands run there. */
3679
+ async switchTab(sessionId, tabId) {
3680
+ const record = await this.ensureSession(sessionId);
3681
+ const tab = (await this.browser.listTabs(record.cdpUrl)).find((t) => t.id === tabId);
3682
+ if (!tab) throw new Error(`Tab not found: ${tabId}`);
3683
+ await this.browser.activateTab(record.cdpUrl, tab.id);
3684
+ this.store.save({
3685
+ ...record,
3686
+ targetWsUrl: tab.targetWsUrl,
3687
+ lastUrl: tab.url || record.lastUrl
3688
+ });
3689
+ this.recordEvent(sessionId, "lifecycle", "info", `Switched to tab ${tab.id}`);
3690
+ return tab;
3691
+ }
3692
+ async newTab(sessionId) {
3693
+ const record = await this.ensureSession(sessionId);
3694
+ const tab = await this.browser.openTab(record.cdpUrl);
3695
+ await this.browser.activateTab(record.cdpUrl, tab.id);
3696
+ this.store.save({
3697
+ ...record,
3698
+ targetWsUrl: tab.targetWsUrl,
3699
+ lastUrl: tab.url
3700
+ });
3701
+ this.recordEvent(sessionId, "lifecycle", "info", `Opened tab ${tab.id}`);
3702
+ return tab;
3703
+ }
3704
+ /** Closes a tab; closing the active one moves the session to a remaining tab. */
3705
+ async closeTab(sessionId, tabId) {
3706
+ const record = await this.ensureSession(sessionId);
3707
+ const tabs = await this.browser.listTabs(record.cdpUrl);
3708
+ const target = tabs.find((t) => t.id === tabId);
3709
+ if (!target) throw new Error(`Tab not found: ${tabId}`);
3710
+ const remaining = tabs.filter((t) => t.id !== tabId);
3711
+ if (remaining.length === 0) throw new Error("Refusing to close the last tab; use agent stop to end the session");
3712
+ await this.browser.closeTab(record.cdpUrl, tabId);
3713
+ let active = tabs.find((t) => t.targetWsUrl === record.targetWsUrl) ?? remaining[0];
3714
+ if (target.targetWsUrl === record.targetWsUrl) {
3715
+ active = remaining[remaining.length - 1];
3716
+ await this.browser.activateTab(record.cdpUrl, active.id);
3717
+ this.store.save({
3718
+ ...record,
3719
+ targetWsUrl: active.targetWsUrl,
3720
+ lastUrl: active.url
3721
+ });
3722
+ }
3723
+ this.recordEvent(sessionId, "lifecycle", "info", `Closed tab ${tabId}`);
3724
+ return {
3725
+ closed: tabId,
3726
+ active
3727
+ };
3728
+ }
3156
3729
  async dismissCookieBanner(sessionId) {
3157
3730
  const record = await this.ensureSession(sessionId);
3158
3731
  const result = await this.browser.dismissCookieBanner(record.targetWsUrl);
@@ -3437,6 +4010,21 @@ var ControlApi = class {
3437
4010
  async getCurrentUrl(sessionId) {
3438
4011
  return await this.sessions.getCurrentUrl(sessionId);
3439
4012
  }
4013
+ async getPageState(sessionId) {
4014
+ return await this.sessions.getPageState(sessionId);
4015
+ }
4016
+ async listTabs(sessionId) {
4017
+ return await this.sessions.listTabs(sessionId);
4018
+ }
4019
+ async switchTab(sessionId, tabId) {
4020
+ return await this.sessions.switchTab(sessionId, tabId);
4021
+ }
4022
+ async newTab(sessionId) {
4023
+ return await this.sessions.newTab(sessionId);
4024
+ }
4025
+ async closeTab(sessionId, tabId) {
4026
+ return await this.sessions.closeTab(sessionId, tabId);
4027
+ }
3440
4028
  listEvents(sessionId, limit = 100) {
3441
4029
  return { events: this.sessions.listEvents(sessionId, limit) };
3442
4030
  }
@@ -4580,7 +5168,10 @@ var AgenticBrowserCore = class {
4580
5168
  roles: input.roles,
4581
5169
  visibleOnly: input.visibleOnly,
4582
5170
  limit: input.limit,
4583
- selector: input.selector
5171
+ selector: input.selector,
5172
+ href: input.href,
5173
+ text: input.text,
5174
+ frame: input.frame
4584
5175
  });
4585
5176
  }
4586
5177
  async dismissCookieBanner(sessionId) {
@@ -4592,6 +5183,21 @@ var AgenticBrowserCore = class {
4592
5183
  async getCurrentUrl(sessionId) {
4593
5184
  return await this.api.getCurrentUrl(sessionId);
4594
5185
  }
5186
+ async getPageState(sessionId) {
5187
+ return await this.api.getPageState(sessionId);
5188
+ }
5189
+ async listTabs(sessionId) {
5190
+ return await this.api.listTabs(sessionId);
5191
+ }
5192
+ async switchTab(sessionId, tabId) {
5193
+ return await this.api.switchTab(sessionId, tabId);
5194
+ }
5195
+ async newTab(sessionId) {
5196
+ return await this.api.newTab(sessionId);
5197
+ }
5198
+ async closeTab(sessionId, tabId) {
5199
+ return await this.api.closeTab(sessionId, tabId);
5200
+ }
4595
5201
  async restartSession(sessionId) {
4596
5202
  return await this.api.restartSession(sessionId);
4597
5203
  }