agentic-browser 2.6.1 → 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.
@@ -8,7 +8,33 @@ import WebSocket from "ws";
8
8
  import crypto from "node:crypto";
9
9
  import { URL as URL$1 } from "node:url";
10
10
  import { z } from "zod";
11
- //#region src/session/chrome-launcher.ts
11
+ /**
12
+ * Create a directory (recursively) and tighten it to owner-only if it already existed
13
+ * with wider permissions (skips home, filesystem root, and dirs owned by another user).
14
+ */
15
+ function ensurePrivateDir(dir) {
16
+ fs.mkdirSync(dir, {
17
+ recursive: true,
18
+ mode: 448
19
+ });
20
+ try {
21
+ if (process.platform === "win32" || typeof process.getuid !== "function") return;
22
+ const resolved = path.resolve(dir);
23
+ if (resolved === path.parse(resolved).root || resolved === path.resolve(os.homedir())) return;
24
+ const st = fs.statSync(resolved);
25
+ if (!st.isDirectory() || st.uid !== process.getuid()) return;
26
+ if ((st.mode & 63) !== 0) fs.chmodSync(resolved, 448);
27
+ } catch {}
28
+ }
29
+ /** Write a file with owner-only permissions, tightening any pre-existing file. */
30
+ function writePrivateFileSync(file, data) {
31
+ fs.writeFileSync(file, data, {
32
+ encoding: "utf8",
33
+ mode: 384
34
+ });
35
+ fs.chmodSync(file, 384);
36
+ }
37
+ // src/session/chrome-launcher.ts
12
38
  const CANDIDATES = [
13
39
  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
14
40
  "/usr/bin/google-chrome",
@@ -21,8 +47,36 @@ function discoverChrome(explicitPath) {
21
47
  if (!found) throw new Error("No supported Chrome installation found.");
22
48
  return found;
23
49
  }
24
- //#endregion
25
- //#region src/session/browser-controller.ts
50
+ // src/session/browser-controller.ts
51
+ /**
52
+ * Detect a running Chrome that owns `profileDir`. On macOS/Linux `SingletonLock`
53
+ * is a dangling symlink to `<hostname>-<pid>`, so existsSync (which follows
54
+ * symlinks) cannot see it; inspect the link itself and probe the pid.
55
+ */
56
+ function isProfileLocked(profileDir) {
57
+ const lockFile = path.join(profileDir, "SingletonLock");
58
+ let target;
59
+ try {
60
+ if (!fs.lstatSync(lockFile).isSymbolicLink()) return { locked: true };
61
+ target = fs.readlinkSync(lockFile);
62
+ } catch {
63
+ return { locked: false };
64
+ }
65
+ const pid = Number(target.slice(target.lastIndexOf("-") + 1));
66
+ if (!Number.isInteger(pid) || pid <= 0) return { locked: true };
67
+ try {
68
+ process.kill(pid, 0);
69
+ return {
70
+ locked: true,
71
+ pid
72
+ };
73
+ } catch (e) {
74
+ return {
75
+ locked: e.code === "EPERM",
76
+ pid
77
+ };
78
+ }
79
+ }
26
80
  var CdpConnection = class CdpConnection {
27
81
  ws;
28
82
  nextId = 0;
@@ -339,6 +393,28 @@ async function initTarget(targetWsUrl, userAgent) {
339
393
  conn.close();
340
394
  }
341
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
+ }`;
342
418
  /**
343
419
  * Run Runtime.evaluate and surface in-page exceptions as errors.
344
420
  * CDP reports page-side throws via `exceptionDetails` on an otherwise successful
@@ -439,6 +515,82 @@ const LOCATOR_RUNTIME_HELPERS = String.raw`
439
515
  return false;
440
516
  }
441
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
+
442
594
  function resolveLocator(locator) {
443
595
  const parts = splitLocator(locator);
444
596
  if (parts.length === 0) return null;
@@ -446,7 +598,7 @@ const LOCATOR_RUNTIME_HELPERS = String.raw`
446
598
  let root = document;
447
599
  let element = null;
448
600
  for (let index = 0; index < parts.length; index += 1) {
449
- element = root.querySelector(parts[index]);
601
+ element = querySegment(root, parts[index]);
450
602
  if (!element) return null;
451
603
 
452
604
  if (index === parts.length - 1) {
@@ -502,7 +654,7 @@ const LOCATOR_RUNTIME_HELPERS = String.raw`
502
654
  if (lastError) {
503
655
  throw lastError;
504
656
  }
505
- throw new Error('Selector not found');
657
+ throw new Error('Element not found: ' + locators.join(' | '));
506
658
  }
507
659
 
508
660
  function ensureInView(element) {
@@ -749,7 +901,7 @@ function buildKeyDispatchPayload(inputKey) {
749
901
  };
750
902
  }
751
903
  /** Connection pool configuration */
752
- const CONNECTION_IDLE_TIMEOUT_MS = 300 * 1e3;
904
+ const CONNECTION_IDLE_TIMEOUT_MS = 3e5;
753
905
  const CONNECTION_CLEANUP_INTERVAL_MS = 6e4;
754
906
  /**
755
907
  * True when the error means the socket was closed/not-open — i.e. the request
@@ -765,6 +917,7 @@ var ChromeCdpBrowserController = class {
765
917
  baseDir;
766
918
  connectionFactory;
767
919
  connections = /* @__PURE__ */ new Map();
920
+ pendingConnections = /* @__PURE__ */ new Map();
768
921
  cleanupInterval;
769
922
  /**
770
923
  * User-agent override resolved at target init, keyed by target ws URL.
@@ -793,17 +946,27 @@ var ChromeCdpBrowserController = class {
793
946
  cached.lastUsedAt = Date.now();
794
947
  return cached.conn;
795
948
  }
796
- const conn = await this.connectionFactory(targetWsUrl);
797
- await this.applySessionOverrides(conn, targetWsUrl);
798
- this.connections.set(targetWsUrl, {
799
- conn,
800
- enabled: {
801
- page: false,
802
- runtime: false
803
- },
804
- lastUsedAt: Date.now()
805
- });
806
- return conn;
949
+ const pending = this.pendingConnections.get(targetWsUrl);
950
+ if (pending) return pending;
951
+ const p = (async () => {
952
+ const conn = await this.connectionFactory(targetWsUrl);
953
+ await this.applySessionOverrides(conn, targetWsUrl);
954
+ this.connections.set(targetWsUrl, {
955
+ conn,
956
+ enabled: {
957
+ page: false,
958
+ runtime: false
959
+ },
960
+ lastUsedAt: Date.now()
961
+ });
962
+ return conn;
963
+ })();
964
+ this.pendingConnections.set(targetWsUrl, p);
965
+ try {
966
+ return await p;
967
+ } finally {
968
+ this.pendingConnections.delete(targetWsUrl);
969
+ }
807
970
  }
808
971
  /**
809
972
  * Re-apply session-scoped stealth patches + UA override on a fresh connection.
@@ -883,9 +1046,11 @@ var ChromeCdpBrowserController = class {
883
1046
  if (userProfileDir === "default") profileDir = resolveDefaultProfileDir();
884
1047
  else if (userProfileDir) profileDir = userProfileDir;
885
1048
  else profileDir = path.join(this.baseDir, "profiles", sessionId);
886
- fs.mkdirSync(profileDir, { recursive: true });
1049
+ if (userProfileDir) fs.mkdirSync(profileDir, { recursive: true });
1050
+ else ensurePrivateDir(profileDir);
887
1051
  const lockFile = path.join(profileDir, "SingletonLock");
888
- if (fs.existsSync(lockFile)) throw new Error(`Chrome profile is already in use (lock file exists: ${lockFile}). Quit the running Chrome instance first, or use --cdp-url to connect to it instead.`);
1052
+ const lock = isProfileLocked(profileDir);
1053
+ if (lock.locked) throw new Error(`Chrome profile is already in use (lock file exists: ${lockFile}${lock.pid ? `, pid ${lock.pid}` : ""}). Quit the running Chrome instance first, or use --cdp-url to connect to it instead.`);
889
1054
  const launchAttempts = headless ? [{ headless: true }] : [{ headless: false }, { headless: true }];
890
1055
  let lastError;
891
1056
  for (const attempt of launchAttempts) {
@@ -948,6 +1113,182 @@ var ChromeCdpBrowserController = class {
948
1113
  return await fn(conn);
949
1114
  }
950
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
+ }
951
1292
  async navigate(targetWsUrl, url) {
952
1293
  return await this.withRetry(targetWsUrl, async (conn) => {
953
1294
  await this.ensureEnabled(targetWsUrl);
@@ -1078,6 +1419,40 @@ var ChromeCdpBrowserController = class {
1078
1419
  if (value !== "ready" && value !== "cleared") throw new Error("Unable to resolve type target");
1079
1420
  return value;
1080
1421
  }
1422
+ /**
1423
+ * Arm a wait for a main-frame navigation BEFORE the click is dispatched, so
1424
+ * the start event cannot be missed. Resolves "navigated" once the main frame
1425
+ * started loading and then committed/loaded (each stage timeout-tolerant),
1426
+ * or "none" when no main-frame load started within 500 ms.
1427
+ */
1428
+ async armMainFrameNavigationWait(conn) {
1429
+ const matching = conn.waitForEventMatching?.bind(conn);
1430
+ if (!matching) return { result: (async () => {
1431
+ try {
1432
+ await conn.waitForEvent("Page.frameNavigated", 50);
1433
+ } catch {
1434
+ return "none";
1435
+ }
1436
+ try {
1437
+ await Promise.race([conn.waitForEvent("Page.loadEventFired", 3e3), conn.waitForEvent("Page.frameStoppedLoading", 3e3)]);
1438
+ } catch {}
1439
+ return "navigated";
1440
+ })() };
1441
+ let mainFrameId;
1442
+ try {
1443
+ mainFrameId = (await conn.send("Page.getFrameTree")).frameTree?.frame?.id;
1444
+ } catch {}
1445
+ const isMain = (params) => mainFrameId === void 0 || params.frameId === mainFrameId;
1446
+ const started = matching("Page.frameStartedLoading", isMain, 500).then(() => true, () => false);
1447
+ return { result: (async () => {
1448
+ if (!await started) return "none";
1449
+ const navigated = matching("Page.frameNavigated", (params) => !params.frame?.parentId, 5e3).catch(() => void 0);
1450
+ const loaded = Promise.race([matching("Page.frameStoppedLoading", isMain, 5e3), matching("Page.loadEventFired", () => true, 5e3)]).catch(() => void 0);
1451
+ await navigated;
1452
+ await loaded;
1453
+ return "navigated";
1454
+ })() };
1455
+ }
1081
1456
  async dispatchMouseClick(conn, x, y) {
1082
1457
  await conn.send("Input.dispatchMouseEvent", {
1083
1458
  type: "mouseMoved",
@@ -1175,36 +1550,36 @@ var ChromeCdpBrowserController = class {
1175
1550
  if (payload.action === "dialog") return await this.handleDialogAction(targetWsUrl, payload);
1176
1551
  if (payload.action === "click" || payload.action === "hover" || payload.action === "type" || payload.action === "press") return await this.withRetry(targetWsUrl, async (conn) => {
1177
1552
  await this.ensureEnabled(targetWsUrl);
1178
- if (payload.action === "click") {
1179
- const point = await this.preparePointerTarget(conn, payload);
1180
- await this.dispatchMouseClick(conn, point.x, point.y);
1181
- try {
1182
- await conn.waitForEvent("Page.frameNavigated", 50);
1183
- try {
1184
- await Promise.race([conn.waitForEvent("Page.loadEventFired", 3e3), conn.waitForEvent("Page.frameStoppedLoading", 3e3)]);
1185
- } catch {}
1186
- } catch {}
1187
- return "clicked";
1188
- }
1189
- if (payload.action === "hover") {
1190
- const point = await this.preparePointerTarget(conn, payload);
1191
- await conn.send("Input.dispatchMouseEvent", {
1192
- type: "mouseMoved",
1193
- x: point.x,
1194
- y: point.y,
1195
- button: "none"
1196
- });
1197
- return "hovered";
1198
- }
1199
- if (payload.action === "press") {
1200
- await this.focusTarget(conn, payload);
1201
- await this.dispatchKeyPress(conn, payload.key);
1202
- return "pressed";
1203
- }
1204
- if (await this.prepareTypeTarget(conn, payload) === "ready") await conn.send("Input.insertText", { text: payload.text ?? "" });
1205
- 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
+ });
1206
1581
  }, "connection");
1207
- const expression = buildLocatorRuntimeExpression("payload", payload, `
1582
+ const buildExpression = (scoped) => buildLocatorRuntimeExpression("payload", scoped, `
1208
1583
  if (payload.action === 'waitFor') {
1209
1584
  const timeout = payload.timeoutMs ?? 2000;
1210
1585
  const started = Date.now();
@@ -1251,11 +1626,17 @@ var ChromeCdpBrowserController = class {
1251
1626
  `);
1252
1627
  return await this.withRetry(targetWsUrl, async (conn) => {
1253
1628
  await this.ensureEnabled(targetWsUrl);
1254
- return (await evaluateOnPage(conn, {
1255
- expression,
1256
- returnByValue: true,
1257
- awaitPromise: true
1258
- })).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
+ });
1259
1640
  }, "connection");
1260
1641
  }
1261
1642
  async screenshot(targetWsUrl, options) {
@@ -1291,6 +1672,66 @@ var ChromeCdpBrowserController = class {
1291
1672
  })).result.value ?? "";
1292
1673
  });
1293
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
+ }
1294
1735
  async getContent(targetWsUrl, options) {
1295
1736
  if (options.mode === "summary") return await this.getSummaryContent(targetWsUrl, options);
1296
1737
  if (options.mode === "a11y") {
@@ -1676,6 +2117,10 @@ var ChromeCdpBrowserController = class {
1676
2117
  const limit = options.limit ?? 50;
1677
2118
  const scopeSelector = options.selector;
1678
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
+ : '';
1679
2124
  const candidateSelector = [
1680
2125
  'a[href]',
1681
2126
  'button',
@@ -1947,6 +2392,7 @@ var ChromeCdpBrowserController = class {
1947
2392
  }
1948
2393
 
1949
2394
  const results = [];
2395
+ const crossOriginFrames = [];
1950
2396
  let totalFound = 0;
1951
2397
 
1952
2398
  function maybeRecordElement(root, path, el) {
@@ -1964,6 +2410,25 @@ var ChromeCdpBrowserController = class {
1964
2410
  const visible = isVisible(el);
1965
2411
  if (visibleOnly && !visible) return;
1966
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
+
1967
2432
  totalFound += 1;
1968
2433
  if (results.length >= limit) return;
1969
2434
 
@@ -2007,12 +2472,21 @@ var ChromeCdpBrowserController = class {
2007
2472
  }
2008
2473
 
2009
2474
  if (current.tagName && current.tagName.toLowerCase() === 'iframe') {
2475
+ let frameDocument = null;
2010
2476
  try {
2011
- if (current.contentDocument) {
2012
- walkRoot(current.contentDocument, path.concat(buildSelector(root, current)));
2013
- }
2477
+ frameDocument = current.contentDocument;
2014
2478
  } catch {
2015
- // 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);
2016
2490
  }
2017
2491
  }
2018
2492
 
@@ -2023,7 +2497,9 @@ var ChromeCdpBrowserController = class {
2023
2497
  }
2024
2498
 
2025
2499
  walkRoot(document, []);
2026
- 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;
2027
2503
  `);
2028
2504
  const emptyResult = {
2029
2505
  elements: [],
@@ -2037,12 +2513,31 @@ var ChromeCdpBrowserController = class {
2037
2513
  };
2038
2514
  return await this.withRetry(targetWsUrl, async (conn) => {
2039
2515
  await this.ensureEnabled(targetWsUrl);
2040
- const result = await evaluateOnPage(conn, {
2516
+ const evaluate = async (client) => extract(await evaluateOnPage(client, {
2041
2517
  expression,
2042
2518
  returnByValue: true,
2043
2519
  awaitPromise: true
2044
- });
2045
- 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
+ }
2046
2541
  });
2047
2542
  }
2048
2543
  /**
@@ -2056,22 +2551,33 @@ var ChromeCdpBrowserController = class {
2056
2551
  * Returns silently on failure — never blocks navigation.
2057
2552
  */
2058
2553
  async dismissCookieBanner(targetWsUrl) {
2554
+ const notes = /* @__PURE__ */ new Set();
2059
2555
  for (let attempt = 0; attempt < 2; attempt++) {
2060
2556
  try {
2061
2557
  const domResult = await this.dismissViaDom(targetWsUrl);
2062
2558
  if (domResult.dismissed) return domResult;
2063
- } catch {}
2559
+ } catch (error) {
2560
+ notes.add(`dom: ${error.message}`);
2561
+ }
2064
2562
  try {
2065
2563
  const iframeResult = await this.dismissViaIframe(targetWsUrl);
2066
2564
  if (iframeResult.dismissed) return iframeResult;
2067
- } catch {}
2565
+ if (iframeResult.detail) notes.add(iframeResult.detail);
2566
+ } catch (error) {
2567
+ notes.add(`iframe: ${error.message}`);
2568
+ }
2068
2569
  try {
2069
2570
  const a11yResult = await this.dismissViaA11y(targetWsUrl);
2070
2571
  if (a11yResult.dismissed) return a11yResult;
2071
- } catch {}
2572
+ } catch (error) {
2573
+ notes.add(`a11y: ${error.message}`);
2574
+ }
2072
2575
  if (attempt === 0) await new Promise((resolve) => setTimeout(resolve, 800));
2073
2576
  }
2074
- 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
+ };
2075
2581
  }
2076
2582
  async dismissViaDom(targetWsUrl) {
2077
2583
  const expression = `(() => {
@@ -2326,7 +2832,42 @@ var ChromeCdpBrowserController = class {
2326
2832
  * These banners render inside an iframe that needs to be accessed separately.
2327
2833
  */
2328
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
+ }`;
2329
2869
  const expression = `(() => {
2870
+ const clickAccept = ${innerSearch};
2330
2871
  // Known consent iframe selectors
2331
2872
  const iframeSelectors = [
2332
2873
  'iframe[id^="sp_message_iframe"]', // Sourcepoint (dynamic ID suffix)
@@ -2347,62 +2888,69 @@ var ChromeCdpBrowserController = class {
2347
2888
  'iframe[src*="consentmanager"]',
2348
2889
  ];
2349
2890
 
2891
+ // Cross-origin consent frames are handed back so the caller can attach to them.
2892
+ const crossOrigin = [];
2350
2893
  for (const sel of iframeSelectors) {
2894
+ const iframe = document.querySelector(sel);
2895
+ if (!iframe) continue;
2896
+ let doc = null;
2351
2897
  try {
2352
- const iframe = document.querySelector(sel);
2353
- if (!iframe) continue;
2354
- const doc = iframe.contentDocument || iframe.contentWindow?.document;
2355
- if (!doc) continue;
2356
-
2357
- // Try known selectors inside the iframe
2358
- const innerSelectors = [
2359
- // Sourcepoint specific
2360
- 'button.sp_choice_type_11', // Accept all
2361
- 'button.sp_choice_type_ACCEPT_ALL', // Accept all variant
2362
- 'button.sp_choice_type_SAVE_AND_EXIT', // Save & exit
2363
- '.priv-save-btn', // Sourcepoint frame
2364
- '.message-button', // Generic Sourcepoint
2365
- // Title-based
2366
- 'button[title*="Accept"]', 'button[title*="accept"]',
2367
- 'button[title*="Agree"]', 'button[title*="agree"]',
2368
- 'button[title*="Allow"]',
2369
- 'button[title*="Akzeptieren"]', 'button[title*="akzeptieren"]',
2370
- 'button[title*="Zustimmen"]',
2371
- ];
2372
- for (const innerSel of innerSelectors) {
2373
- const btn = doc.querySelector(innerSel);
2374
- if (btn && btn.offsetWidth > 0) {
2375
- btn.click();
2376
- return JSON.stringify({ dismissed: true, method: 'iframe', detail: sel + ' > ' + innerSel });
2377
- }
2378
- }
2379
-
2380
- // Text-based fallback inside iframe
2381
- const buttons = doc.querySelectorAll('button, [role="button"]');
2382
- for (const btn of buttons) {
2383
- const text = (btn.textContent || '').trim();
2384
- if (text.length > 60) continue;
2385
- if (/accept|agree|allow|akzeptieren|zustimmen|einwilligen|accepter|aceptar/i.test(text)) {
2386
- btn.click();
2387
- return JSON.stringify({ dismissed: true, method: 'iframe-text', detail: text });
2388
- }
2389
- }
2390
- } 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 };
2391
2909
  }
2392
-
2393
- return JSON.stringify({ dismissed: false });
2910
+ return { dismissed: false, crossOrigin };
2394
2911
  })()`;
2395
2912
  return await this.withRetry(targetWsUrl, async (conn) => {
2396
2913
  await this.ensureEnabled(targetWsUrl);
2397
- const raw = (await evaluateOnPage(conn, {
2914
+ const value = (await evaluateOnPage(conn, {
2398
2915
  expression,
2399
2916
  returnByValue: true
2400
- })).result.value ?? "{\"dismissed\":false}";
2401
- try {
2402
- return JSON.parse(raw);
2403
- } catch {
2404
- 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
+ }
2405
2949
  }
2950
+ return notes.length ? {
2951
+ dismissed: false,
2952
+ detail: notes.join("; ")
2953
+ } : { dismissed: false };
2406
2954
  });
2407
2955
  }
2408
2956
  /**
@@ -2599,6 +3147,64 @@ var MockBrowserController = class {
2599
3147
  async getCurrentUrl(cdpUrl) {
2600
3148
  return this.pages.get(cdpUrl)?.url ?? "about:blank";
2601
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
+ }
2602
3208
  async getContent(cdpUrl, options) {
2603
3209
  const page = this.pages.get(cdpUrl);
2604
3210
  if (!page) throw new Error("mock page missing");
@@ -2640,8 +3246,7 @@ var MockBrowserController = class {
2640
3246
  terminate(_pid) {}
2641
3247
  closeConnection(_targetWsUrl) {}
2642
3248
  };
2643
- //#endregion
2644
- //#region src/lib/domain-schemas.ts
3249
+ // src/lib/domain-schemas.ts
2645
3250
  const InteractActionSchema = z.enum([
2646
3251
  "click",
2647
3252
  "type",
@@ -2666,7 +3271,8 @@ const InteractPayloadSchema = z.object({
2666
3271
  value: z.string().optional(),
2667
3272
  scrollX: z.number().optional(),
2668
3273
  scrollY: z.number().optional(),
2669
- timeoutMs: z.number().optional()
3274
+ timeoutMs: z.number().optional(),
3275
+ frame: z.string().optional()
2670
3276
  });
2671
3277
  const SessionStatusSchema = z.enum([
2672
3278
  "starting",
@@ -2747,12 +3353,11 @@ z.object({
2747
3353
  createdAt: z.string().datetime(),
2748
3354
  severity: EventSeveritySchema
2749
3355
  });
2750
- //#endregion
2751
- //#region src/session/session-store.ts
3356
+ // src/session/session-store.ts
2752
3357
  var SessionStore = class {
2753
3358
  filePath;
2754
3359
  constructor(baseDir) {
2755
- fs.mkdirSync(baseDir, { recursive: true });
3360
+ ensurePrivateDir(baseDir);
2756
3361
  this.filePath = path.join(baseDir, "sessions.json");
2757
3362
  if (!fs.existsSync(this.filePath)) this.write({ sessions: {} });
2758
3363
  }
@@ -2838,12 +3443,68 @@ var SessionStore = class {
2838
3443
  }
2839
3444
  write(state) {
2840
3445
  const tmp = `${this.filePath}.${process.pid}.tmp`;
2841
- fs.writeFileSync(tmp, JSON.stringify(state, null, 2), "utf8");
3446
+ writePrivateFileSync(tmp, JSON.stringify(state, null, 2));
2842
3447
  fs.renameSync(tmp, this.filePath);
2843
3448
  }
2844
3449
  };
2845
- //#endregion
2846
- //#region src/session/session-manager.ts
3450
+ // src/session/url-policy.ts
3451
+ function assertNavigableUrl(raw, opts) {
3452
+ let parsed;
3453
+ try {
3454
+ parsed = new URL(raw);
3455
+ } catch {
3456
+ throw new Error(`Invalid URL: ${raw}`);
3457
+ }
3458
+ const { protocol } = parsed;
3459
+ if (protocol === "http:" || protocol === "https:") return;
3460
+ if (raw === "about:blank") return;
3461
+ if (protocol === "file:" && opts.allowFileUrls) return;
3462
+ throw new Error(`Navigation to ${protocol} URLs is not allowed${protocol === "file:" ? " (set AGENTIC_BROWSER_ALLOW_FILE_URLS=true to allow file: URLs)" : ""}`);
3463
+ }
3464
+ // src/memory/redact-step-payload.ts
3465
+ const ALLOWED_KEYS = /* @__PURE__ */ new Set([
3466
+ "action",
3467
+ "selector",
3468
+ "fallbackSelectors",
3469
+ "ref",
3470
+ "key",
3471
+ "direction",
3472
+ "amount",
3473
+ "deltaY",
3474
+ "scrollX",
3475
+ "scrollY",
3476
+ "timeoutMs",
3477
+ "redacted"
3478
+ ]);
3479
+ const URL_IN_TEXT = /\bhttps?:\/\/[^\s"'<>]+/gi;
3480
+ /** Reduce a URL to origin + pathname, dropping query string and fragment. */
3481
+ function stripUrl(rawUrl) {
3482
+ try {
3483
+ const parsed = new URL(rawUrl);
3484
+ return `${parsed.origin}${parsed.pathname}`;
3485
+ } catch {
3486
+ return;
3487
+ }
3488
+ }
3489
+ /** Strip query strings and fragments from every http(s) URL embedded in free text. */
3490
+ function redactUrlsInText(text) {
3491
+ return text.replace(URL_IN_TEXT, (match) => stripUrl(match) ?? "[url]");
3492
+ }
3493
+ /** Keep only structural, non-secret keys of a step payload; typed text and values are dropped. */
3494
+ function redactStepPayload(payload) {
3495
+ const result = {};
3496
+ for (const [key, value] of Object.entries(payload)) {
3497
+ if (key === "url") {
3498
+ const stripped = typeof value === "string" ? stripUrl(value) : void 0;
3499
+ if (stripped !== void 0) result.url = stripped;
3500
+ continue;
3501
+ }
3502
+ if (ALLOWED_KEYS.has(key)) result[key] = value;
3503
+ }
3504
+ if ("text" in payload || "value" in payload) result.redacted = true;
3505
+ return result;
3506
+ }
3507
+ // src/session/session-manager.ts
2847
3508
  var SessionManager = class {
2848
3509
  ctx;
2849
3510
  browser;
@@ -2926,6 +3587,7 @@ var SessionManager = class {
2926
3587
  if (input.type === "navigate") {
2927
3588
  const url = String(input.payload.url ?? "");
2928
3589
  if (!url) throw new Error("navigate command requires payload.url");
3590
+ assertNavigableUrl(url, { allowFileUrls: this.ctx.config.allowFileUrls });
2929
3591
  const finalUrl = await this.browser.navigate(record.targetWsUrl, url);
2930
3592
  this.store.setLastUrl(sessionId, finalUrl);
2931
3593
  resultMessage = `Navigated to ${finalUrl}`;
@@ -3001,6 +3663,68 @@ var SessionManager = class {
3001
3663
  const record = await this.ensureSession(sessionId);
3002
3664
  return await this.browser.getCurrentUrl(record.targetWsUrl);
3003
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
+ }
3004
3728
  async dismissCookieBanner(sessionId) {
3005
3729
  const record = await this.ensureSession(sessionId);
3006
3730
  const result = await this.browser.dismissCookieBanner(record.targetWsUrl);
@@ -3219,7 +3943,7 @@ var SessionManager = class {
3219
3943
  type: input.type === "navigate" ? "navigate" : input.type === "interact" ? "interact" : "assert",
3220
3944
  summary: input.type === "interact" ? `interact:${action ?? "unknown"}` : input.type,
3221
3945
  selector,
3222
- payload: input.payload
3946
+ payload: redactStepPayload(input.payload)
3223
3947
  },
3224
3948
  selector,
3225
3949
  url: inputUrl
@@ -3246,8 +3970,7 @@ var SessionManager = class {
3246
3970
  }
3247
3971
  }
3248
3972
  };
3249
- //#endregion
3250
- //#region src/transport/control-api.ts
3973
+ // src/transport/control-api.ts
3251
3974
  var ControlApi = class {
3252
3975
  sessions;
3253
3976
  constructor(sessions) {
@@ -3286,6 +4009,21 @@ var ControlApi = class {
3286
4009
  async getCurrentUrl(sessionId) {
3287
4010
  return await this.sessions.getCurrentUrl(sessionId);
3288
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
+ }
3289
4027
  listEvents(sessionId, limit = 100) {
3290
4028
  return { events: this.sessions.listEvents(sessionId, limit) };
3291
4029
  }
@@ -3305,8 +4043,7 @@ var ControlApi = class {
3305
4043
  return this.sessions.cleanupSessions(input);
3306
4044
  }
3307
4045
  };
3308
- //#endregion
3309
- //#region src/lib/config.ts
4046
+ // src/lib/config.ts
3310
4047
  function loadConfig(env = process.env) {
3311
4048
  const userProfile = env.AGENTIC_BROWSER_USER_PROFILE;
3312
4049
  let userProfileDir;
@@ -3318,11 +4055,11 @@ function loadConfig(env = process.env) {
3318
4055
  cdpUrl: env.AGENTIC_BROWSER_CDP_URL,
3319
4056
  userProfileDir,
3320
4057
  headless: env.AGENTIC_BROWSER_HEADLESS === "true" || env.AGENTIC_BROWSER_HEADLESS === "1",
3321
- userAgent: env.AGENTIC_BROWSER_USER_AGENT || void 0
4058
+ userAgent: env.AGENTIC_BROWSER_USER_AGENT || void 0,
4059
+ allowFileUrls: env.AGENTIC_BROWSER_ALLOW_FILE_URLS === "true" || env.AGENTIC_BROWSER_ALLOW_FILE_URLS === "1"
3322
4060
  };
3323
4061
  }
3324
- //#endregion
3325
- //#region src/observability/logger.ts
4062
+ // src/observability/logger.ts
3326
4063
  var Logger = class {
3327
4064
  namespace;
3328
4065
  constructor(namespace) {
@@ -3353,8 +4090,7 @@ var Logger = class {
3353
4090
  this.emit("error", message, context);
3354
4091
  }
3355
4092
  };
3356
- //#endregion
3357
- //#region src/observability/event-store.ts
4093
+ // src/observability/event-store.ts
3358
4094
  const MAX_EVENTS_PER_SESSION = 500;
3359
4095
  var EventStore = class {
3360
4096
  events = /* @__PURE__ */ new Map();
@@ -3364,8 +4100,11 @@ var EventStore = class {
3364
4100
  pendingFlushDelayMs = Number.POSITIVE_INFINITY;
3365
4101
  flushInFlight;
3366
4102
  constructor(baseDir) {
3367
- fs.mkdirSync(baseDir, { recursive: true });
4103
+ ensurePrivateDir(baseDir);
3368
4104
  this.filePath = path.join(baseDir, "session-events.log");
4105
+ try {
4106
+ if (fs.existsSync(this.filePath)) fs.chmodSync(this.filePath, 384);
4107
+ } catch {}
3369
4108
  process.on("beforeExit", this.flushSync);
3370
4109
  process.on("exit", this.flushSync);
3371
4110
  }
@@ -3416,7 +4155,10 @@ var EventStore = class {
3416
4155
  async flushAsync() {
3417
4156
  if (this.flushInFlight || this.bufferedLines.length === 0) return;
3418
4157
  const chunk = this.bufferedLines.splice(0, this.bufferedLines.length).join("");
3419
- this.flushInFlight = fs.promises.appendFile(this.filePath, chunk, "utf8").catch(() => {}).finally(() => {
4158
+ this.flushInFlight = fs.promises.appendFile(this.filePath, chunk, {
4159
+ encoding: "utf8",
4160
+ mode: 384
4161
+ }).catch(() => {}).finally(() => {
3420
4162
  this.flushInFlight = void 0;
3421
4163
  if (this.bufferedLines.length > 0) this.flushSoon(0);
3422
4164
  });
@@ -3430,12 +4172,14 @@ var EventStore = class {
3430
4172
  if (this.bufferedLines.length === 0) return;
3431
4173
  const chunk = this.bufferedLines.splice(0, this.bufferedLines.length).join("");
3432
4174
  try {
3433
- fs.appendFileSync(this.filePath, chunk, "utf8");
4175
+ fs.appendFileSync(this.filePath, chunk, {
4176
+ encoding: "utf8",
4177
+ mode: 384
4178
+ });
3434
4179
  } catch {}
3435
4180
  };
3436
4181
  };
3437
- //#endregion
3438
- //#region src/auth/session-token.ts
4182
+ // src/auth/session-token.ts
3439
4183
  var SessionTokenService = class {
3440
4184
  tokens = /* @__PURE__ */ new Map();
3441
4185
  issue(sessionId) {
@@ -3461,8 +4205,7 @@ var SessionTokenService = class {
3461
4205
  this.tokens.set(sessionId, token);
3462
4206
  }
3463
4207
  };
3464
- //#endregion
3465
- //#region src/memory/memory-index.ts
4208
+ // src/memory/memory-index.ts
3466
4209
  function normalize(value) {
3467
4210
  return value.trim().toLowerCase();
3468
4211
  }
@@ -3571,8 +4314,7 @@ var MemoryIndex = class {
3571
4314
  return candidates.map((insight) => scoreInsight(insight, normalizedIntent, normalizedDomain, profileLookup)).filter(relevant).toSorted((a, b) => b.result.score - a.result.score).slice(0, limit).map((scored) => scored.result);
3572
4315
  }
3573
4316
  };
3574
- //#endregion
3575
- //#region src/memory/staleness-detector.ts
4317
+ // src/memory/staleness-detector.ts
3576
4318
  const MAX_SUSPECT_STRIKES = 2;
3577
4319
  function detectStalenessSignal(errorMessage, selector) {
3578
4320
  const lowered = errorMessage.toLowerCase();
@@ -3600,7 +4342,7 @@ function applyFailure(insight, signal) {
3600
4342
  };
3601
4343
  }
3602
4344
  function applyAgeStaleness(insight) {
3603
- const ageDays = (Date.now() - Date.parse(insight.lastVerifiedAt)) / (1440 * 60 * 1e3);
4345
+ const ageDays = (Date.now() - Date.parse(insight.lastVerifiedAt)) / 864e5;
3604
4346
  if (insight.freshness === "fresh" && ageDays > 30) return {
3605
4347
  ...insight,
3606
4348
  freshness: "suspect",
@@ -3627,8 +4369,57 @@ function applySuccess(insight) {
3627
4369
  updatedAt: now
3628
4370
  };
3629
4371
  }
3630
- //#endregion
3631
- //#region src/memory/memory-schemas.ts
4372
+ // src/lib/flush-registry.ts
4373
+ const REGISTRY = Symbol.for("agentic-browser.flush-registry");
4374
+ function flushAll(items) {
4375
+ for (const item of items) try {
4376
+ item.flushSync();
4377
+ } catch {}
4378
+ }
4379
+ function createRegistry() {
4380
+ const items = /* @__PURE__ */ new Set();
4381
+ const onSignal = (signal) => {
4382
+ flushAll(items);
4383
+ if (process.listenerCount(signal) === 1) process.exit(0);
4384
+ };
4385
+ return {
4386
+ items,
4387
+ onExit: () => flushAll(items),
4388
+ onSigint: () => onSignal("SIGINT"),
4389
+ onSigterm: () => onSignal("SIGTERM")
4390
+ };
4391
+ }
4392
+ /**
4393
+ * Register an item to be flushed synchronously on process exit / SIGINT / SIGTERM.
4394
+ * Returns an idempotent unregister function; the process listeners are removed
4395
+ * once the last item is unregistered.
4396
+ */
4397
+ function registerFlushable(item) {
4398
+ const proc = process;
4399
+ let registry = proc[REGISTRY];
4400
+ if (!registry) {
4401
+ registry = createRegistry();
4402
+ proc[REGISTRY] = registry;
4403
+ process.on("exit", registry.onExit);
4404
+ process.on("SIGINT", registry.onSigint);
4405
+ process.on("SIGTERM", registry.onSigterm);
4406
+ }
4407
+ registry.items.add(item);
4408
+ const owner = registry;
4409
+ let done = false;
4410
+ return () => {
4411
+ if (done) return;
4412
+ done = true;
4413
+ owner.items.delete(item);
4414
+ if (owner.items.size === 0 && proc[REGISTRY] === owner) {
4415
+ process.off("exit", owner.onExit);
4416
+ process.off("SIGINT", owner.onSigint);
4417
+ process.off("SIGTERM", owner.onSigterm);
4418
+ delete proc[REGISTRY];
4419
+ }
4420
+ };
4421
+ }
4422
+ // src/memory/memory-schemas.ts
3632
4423
  const InsightFreshnessSchema = z.enum([
3633
4424
  "fresh",
3634
4425
  "suspect",
@@ -3705,27 +4496,25 @@ const SiteProfileSchema = z.object({
3705
4496
  createdAt: z.string().datetime()
3706
4497
  });
3707
4498
  const SiteProfileStateSchema = z.object({ profiles: z.array(SiteProfileSchema) });
3708
- //#endregion
3709
- //#region src/memory/site-profile-store.ts
4499
+ // src/memory/site-profile-store.ts
3710
4500
  const EMPTY_STATE$1 = { profiles: [] };
3711
4501
  const FLUSH_DELAY_MS$1 = 500;
3712
- const REGISTERED_EXIT_HANDLERS$1 = Symbol.for("agentic-browser.site-profile-store.exit-handlers");
3713
- const REGISTERED_STORES$1 = Symbol.for("agentic-browser.site-profile-store.instances");
3714
4502
  var SiteProfileStore = class {
3715
4503
  filePath;
3716
4504
  cached = null;
3717
4505
  dirty = false;
3718
4506
  flushTimer = null;
4507
+ unregister;
3719
4508
  constructor(baseDir) {
3720
4509
  const memoryDir = path.join(baseDir, "memory");
3721
- fs.mkdirSync(memoryDir, { recursive: true });
4510
+ ensurePrivateDir(memoryDir);
3722
4511
  this.filePath = path.join(memoryDir, "site-profiles.json");
3723
4512
  const tmpPath = `${this.filePath}.tmp`;
3724
4513
  try {
3725
4514
  if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
3726
4515
  } catch {}
3727
4516
  if (!fs.existsSync(this.filePath)) this.writeDisk(EMPTY_STATE$1);
3728
- this.registerProcessHandlers();
4517
+ this.unregister = registerFlushable(this);
3729
4518
  }
3730
4519
  list() {
3731
4520
  return this.getCache();
@@ -3746,6 +4535,11 @@ var SiteProfileStore = class {
3746
4535
  else profiles.push(normalized);
3747
4536
  this.markDirty();
3748
4537
  }
4538
+ /** Flush pending writes and stop flushing on process exit. Idempotent. */
4539
+ dispose() {
4540
+ this.flushSync();
4541
+ this.unregister();
4542
+ }
3749
4543
  flushSync() {
3750
4544
  if (this.flushTimer) {
3751
4545
  clearTimeout(this.flushTimer);
@@ -3802,51 +4596,30 @@ var SiteProfileStore = class {
3802
4596
  }
3803
4597
  writeDisk(state) {
3804
4598
  const tempPath = `${this.filePath}.tmp`;
3805
- fs.writeFileSync(tempPath, JSON.stringify(state, null, 2), "utf8");
4599
+ writePrivateFileSync(tempPath, JSON.stringify(state, null, 2));
3806
4600
  fs.renameSync(tempPath, this.filePath);
3807
4601
  }
3808
- registerProcessHandlers() {
3809
- const proc = process;
3810
- const stores = proc[REGISTERED_STORES$1] ??= /* @__PURE__ */ new Set();
3811
- stores.add(this);
3812
- if (proc[REGISTERED_EXIT_HANDLERS$1]) return;
3813
- proc[REGISTERED_EXIT_HANDLERS$1] = true;
3814
- const flushAll = () => {
3815
- for (const store of stores) store.flushSync();
3816
- };
3817
- process.on("exit", flushAll);
3818
- process.on("SIGINT", () => {
3819
- flushAll();
3820
- process.exit(0);
3821
- });
3822
- process.on("SIGTERM", () => {
3823
- flushAll();
3824
- process.exit(0);
3825
- });
3826
- }
3827
4602
  };
3828
- //#endregion
3829
- //#region src/memory/task-insight-store.ts
4603
+ // src/memory/task-insight-store.ts
3830
4604
  const EMPTY_STATE = { insights: [] };
3831
4605
  const FLUSH_DELAY_MS = 500;
3832
- const REGISTERED_EXIT_HANDLERS = Symbol.for("agentic-browser.task-insight-store.exit-handlers");
3833
- const REGISTERED_STORES = Symbol.for("agentic-browser.task-insight-store.instances");
3834
4606
  var TaskInsightStore = class {
3835
4607
  filePath;
3836
4608
  /** In-memory cache – authoritative after first load. */
3837
4609
  cached = null;
3838
4610
  dirty = false;
3839
4611
  flushTimer = null;
4612
+ unregister;
3840
4613
  constructor(baseDir) {
3841
4614
  const memoryDir = path.join(baseDir, "memory");
3842
- fs.mkdirSync(memoryDir, { recursive: true });
4615
+ ensurePrivateDir(memoryDir);
3843
4616
  this.filePath = path.join(memoryDir, "insights.json");
3844
4617
  const tmpPath = `${this.filePath}.tmp`;
3845
4618
  try {
3846
4619
  if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
3847
4620
  } catch {}
3848
4621
  if (!fs.existsSync(this.filePath)) this.writeDisk(EMPTY_STATE);
3849
- this.registerProcessHandlers();
4622
+ this.unregister = registerFlushable(this);
3850
4623
  }
3851
4624
  list() {
3852
4625
  return this.getCache();
@@ -3876,6 +4649,11 @@ var TaskInsightStore = class {
3876
4649
  this.cached = insights;
3877
4650
  this.markDirty();
3878
4651
  }
4652
+ /** Flush pending writes and stop flushing on process exit. Idempotent. */
4653
+ dispose() {
4654
+ this.flushSync();
4655
+ this.unregister();
4656
+ }
3879
4657
  /** Force an immediate synchronous flush (used at shutdown). */
3880
4658
  flushSync() {
3881
4659
  if (this.flushTimer) {
@@ -3892,8 +4670,52 @@ var TaskInsightStore = class {
3892
4670
  if (this.cached) return this.cached;
3893
4671
  const state = this.readDisk();
3894
4672
  this.cached = state.insights;
4673
+ if (this.sanitizeCached(this.cached)) this.markDirty();
3895
4674
  return this.cached;
3896
4675
  }
4676
+ /**
4677
+ * Redact already-persisted step payloads and URL query strings/fragments in place.
4678
+ * Returns true if anything changed.
4679
+ */
4680
+ sanitizeCached(insights) {
4681
+ let changed = false;
4682
+ for (const insight of insights) {
4683
+ const expectedOutcome = redactUrlsInText(insight.expectedOutcome);
4684
+ if (expectedOutcome !== insight.expectedOutcome) {
4685
+ insight.expectedOutcome = expectedOutcome;
4686
+ changed = true;
4687
+ }
4688
+ const taskIntent = redactUrlsInText(insight.taskIntent);
4689
+ if (taskIntent !== insight.taskIntent) {
4690
+ insight.taskIntent = taskIntent;
4691
+ changed = true;
4692
+ }
4693
+ for (const evidence of insight.evidence) {
4694
+ if (evidence.url !== void 0) {
4695
+ const url = stripUrl(evidence.url);
4696
+ if (url !== evidence.url) {
4697
+ evidence.url = url;
4698
+ changed = true;
4699
+ }
4700
+ }
4701
+ if (evidence.reason !== void 0) {
4702
+ const reason = redactUrlsInText(evidence.reason);
4703
+ if (reason !== evidence.reason) {
4704
+ evidence.reason = reason;
4705
+ changed = true;
4706
+ }
4707
+ }
4708
+ }
4709
+ for (const step of insight.actionRecipe) {
4710
+ const redacted = redactStepPayload(step.payload);
4711
+ if (JSON.stringify(redacted) !== JSON.stringify(step.payload)) {
4712
+ step.payload = redacted;
4713
+ changed = true;
4714
+ }
4715
+ }
4716
+ }
4717
+ return changed;
4718
+ }
3897
4719
  markDirty() {
3898
4720
  this.dirty = true;
3899
4721
  if (!this.flushTimer) this.flushTimer = setTimeout(() => {
@@ -3935,31 +4757,11 @@ var TaskInsightStore = class {
3935
4757
  }
3936
4758
  writeDisk(state) {
3937
4759
  const tempPath = `${this.filePath}.tmp`;
3938
- fs.writeFileSync(tempPath, JSON.stringify(state, null, 2), "utf8");
4760
+ writePrivateFileSync(tempPath, JSON.stringify(state, null, 2));
3939
4761
  fs.renameSync(tempPath, this.filePath);
3940
4762
  }
3941
- registerProcessHandlers() {
3942
- const proc = process;
3943
- const stores = proc[REGISTERED_STORES] ??= /* @__PURE__ */ new Set();
3944
- stores.add(this);
3945
- if (proc[REGISTERED_EXIT_HANDLERS]) return;
3946
- proc[REGISTERED_EXIT_HANDLERS] = true;
3947
- const flushAll = () => {
3948
- for (const store of stores) store.flushSync();
3949
- };
3950
- process.on("exit", flushAll);
3951
- process.on("SIGINT", () => {
3952
- flushAll();
3953
- process.exit(0);
3954
- });
3955
- process.on("SIGTERM", () => {
3956
- flushAll();
3957
- process.exit(0);
3958
- });
3959
- }
3960
4763
  };
3961
- //#endregion
3962
- //#region src/memory/memory-service.ts
4764
+ // src/memory/memory-service.ts
3963
4765
  /** Memory service configuration */
3964
4766
  const SEARCH_CACHE_TTL_MS = 2e3;
3965
4767
  const MAX_SELECTOR_ALIASES = 10;
@@ -3987,6 +4789,11 @@ var MemoryService = class {
3987
4789
  this.store.flushSync();
3988
4790
  this.profileStore.flushSync();
3989
4791
  }
4792
+ /** Flush pending writes and release the stores' process exit hooks. */
4793
+ dispose() {
4794
+ this.store.dispose();
4795
+ this.profileStore.dispose();
4796
+ }
3990
4797
  search(input) {
3991
4798
  const cacheKey = `${input.taskIntent}\0${input.siteDomain ?? ""}\0${input.limit ?? 10}`;
3992
4799
  const now = Date.now();
@@ -4048,7 +4855,8 @@ var MemoryService = class {
4048
4855
  this.invalidateSearchCache();
4049
4856
  return verified;
4050
4857
  }
4051
- recordSuccess(input) {
4858
+ recordSuccess(rawInput) {
4859
+ const input = this.sanitizeInput(rawInput);
4052
4860
  this.updateSiteProfile(input.siteDomain, input.selector, input.url, input.sitePathPattern);
4053
4861
  const insights = this.store.list();
4054
4862
  const matched = this.findBestExactMatch(insights, input);
@@ -4107,13 +4915,14 @@ var MemoryService = class {
4107
4915
  this.invalidateSearchCache();
4108
4916
  return withAliases;
4109
4917
  }
4110
- recordFailure(input, errorMessage) {
4918
+ recordFailure(rawInput, errorMessage) {
4919
+ const input = this.sanitizeInput(rawInput);
4111
4920
  this.updateSiteProfile(input.siteDomain, input.selector, input.url, input.sitePathPattern);
4112
4921
  const insights = this.store.list();
4113
4922
  const matched = this.findBestExactMatch(insights, input);
4114
4923
  if (!matched) return;
4115
4924
  const signal = detectStalenessSignal(errorMessage, input.selector);
4116
- const evidence = this.createEvidence(input, "failure", errorMessage);
4925
+ const evidence = this.createEvidence(input, "failure", redactUrlsInText(errorMessage));
4117
4926
  const failed = applyFailure({
4118
4927
  ...matched,
4119
4928
  useCount: matched.useCount + 1,
@@ -4235,6 +5044,15 @@ var MemoryService = class {
4235
5044
  const rightSegment = normalizePathSegment(right);
4236
5045
  return Boolean(leftSegment) && leftSegment === rightSegment;
4237
5046
  }
5047
+ /** Strip query strings and fragments from every URL-bearing field before it is persisted. */
5048
+ sanitizeInput(input) {
5049
+ return {
5050
+ ...input,
5051
+ url: input.url ? stripUrl(input.url) ?? void 0 : void 0,
5052
+ expectedOutcome: redactUrlsInText(input.expectedOutcome),
5053
+ taskIntent: redactUrlsInText(input.taskIntent)
5054
+ };
5055
+ }
4238
5056
  createEvidence(input, result, reason) {
4239
5057
  return {
4240
5058
  evidenceId: crypto.randomUUID(),
@@ -4287,8 +5105,7 @@ var MemoryService = class {
4287
5105
  return [...recipe, step].slice(-8);
4288
5106
  }
4289
5107
  };
4290
- //#endregion
4291
- //#region src/cli/app.ts
5108
+ // src/cli/app.ts
4292
5109
  function createAppContext(env = process.env) {
4293
5110
  const config = loadConfig(env);
4294
5111
  return {
@@ -4299,17 +5116,31 @@ function createAppContext(env = process.env) {
4299
5116
  memoryService: new MemoryService(config.dataDir)
4300
5117
  };
4301
5118
  }
4302
- //#endregion
4303
- //#region src/cli/runtime.ts
5119
+ // src/cli/runtime.ts
4304
5120
  var AgenticBrowserCore = class {
4305
5121
  context;
4306
5122
  sessions;
4307
5123
  api;
5124
+ browserController;
5125
+ disposed = false;
4308
5126
  constructor(context, browserController) {
4309
5127
  this.context = context;
5128
+ this.browserController = browserController;
4310
5129
  this.sessions = new SessionManager(context, browserController);
4311
5130
  this.api = new ControlApi(this.sessions);
4312
5131
  }
5132
+ /**
5133
+ * Release process-level resources (event store and memory store exit handlers,
5134
+ * controller timers). Flushes pending memory writes.
5135
+ * Does not terminate browser sessions. Idempotent.
5136
+ */
5137
+ async dispose() {
5138
+ if (this.disposed) return;
5139
+ this.disposed = true;
5140
+ this.browserController.dispose?.();
5141
+ this.context.eventStore.dispose();
5142
+ this.context.memoryService.dispose();
5143
+ }
4313
5144
  async startSession(input = { browser: "chrome" }) {
4314
5145
  return await this.api.createSession(input);
4315
5146
  }
@@ -4336,7 +5167,10 @@ var AgenticBrowserCore = class {
4336
5167
  roles: input.roles,
4337
5168
  visibleOnly: input.visibleOnly,
4338
5169
  limit: input.limit,
4339
- selector: input.selector
5170
+ selector: input.selector,
5171
+ href: input.href,
5172
+ text: input.text,
5173
+ frame: input.frame
4340
5174
  });
4341
5175
  }
4342
5176
  async dismissCookieBanner(sessionId) {
@@ -4348,6 +5182,21 @@ var AgenticBrowserCore = class {
4348
5182
  async getCurrentUrl(sessionId) {
4349
5183
  return await this.api.getCurrentUrl(sessionId);
4350
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
+ }
4351
5200
  async restartSession(sessionId) {
4352
5201
  return await this.api.restartSession(sessionId);
4353
5202
  }
@@ -4380,5 +5229,4 @@ function createMockAgenticBrowserCore(env) {
4380
5229
  function createCliRuntime() {
4381
5230
  return createAgenticBrowserCore();
4382
5231
  }
4383
- //#endregion
4384
- export { createMockAgenticBrowserCore as i, createAgenticBrowserCore as n, createCliRuntime as r, AgenticBrowserCore as t };
5232
+ export { isConnectionError as a, createMockAgenticBrowserCore as i, createAgenticBrowserCore as n, ensurePrivateDir as o, createCliRuntime as r, writePrivateFileSync as s, AgenticBrowserCore as t };