browser-use 0.6.1 → 0.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.
Files changed (84) hide show
  1. package/README.md +24 -18
  2. package/dist/actor/element.js +24 -3
  3. package/dist/actor/mouse.js +21 -3
  4. package/dist/actor/page.js +33 -11
  5. package/dist/agent/gif.js +28 -3
  6. package/dist/agent/message-manager/service.js +2 -22
  7. package/dist/agent/message-manager/utils.js +15 -2
  8. package/dist/agent/message-manager/views.d.ts +7 -7
  9. package/dist/agent/message-manager/views.js +1 -0
  10. package/dist/agent/prompts.d.ts +3 -0
  11. package/dist/agent/prompts.js +22 -12
  12. package/dist/agent/service.d.ts +9 -1
  13. package/dist/agent/service.js +204 -79
  14. package/dist/agent/system_prompt.md +12 -11
  15. package/dist/agent/system_prompt_anthropic_flash.md +6 -5
  16. package/dist/agent/system_prompt_no_thinking.md +12 -11
  17. package/dist/agent/views.d.ts +2 -0
  18. package/dist/agent/views.js +48 -36
  19. package/dist/browser/extensions.js +20 -10
  20. package/dist/browser/profile.d.ts +4 -0
  21. package/dist/browser/profile.js +107 -4
  22. package/dist/browser/session.d.ts +28 -1
  23. package/dist/browser/session.js +1436 -528
  24. package/dist/browser/watchdogs/default-action-watchdog.js +32 -3
  25. package/dist/browser/watchdogs/downloads-watchdog.d.ts +4 -0
  26. package/dist/browser/watchdogs/downloads-watchdog.js +105 -9
  27. package/dist/browser/watchdogs/har-recording-watchdog.d.ts +1 -0
  28. package/dist/browser/watchdogs/har-recording-watchdog.js +54 -2
  29. package/dist/browser/watchdogs/permissions-watchdog.d.ts +5 -0
  30. package/dist/browser/watchdogs/permissions-watchdog.js +106 -3
  31. package/dist/browser/watchdogs/recording-watchdog.d.ts +2 -0
  32. package/dist/browser/watchdogs/recording-watchdog.js +54 -2
  33. package/dist/browser/watchdogs/security-watchdog.d.ts +1 -0
  34. package/dist/browser/watchdogs/security-watchdog.js +47 -7
  35. package/dist/browser/watchdogs/storage-state-watchdog.d.ts +6 -0
  36. package/dist/browser/watchdogs/storage-state-watchdog.js +206 -14
  37. package/dist/cli.d.ts +13 -2
  38. package/dist/cli.js +190 -9
  39. package/dist/code-use/namespace.js +52 -7
  40. package/dist/code-use/notebook-export.js +18 -2
  41. package/dist/code-use/service.js +1 -0
  42. package/dist/config.js +26 -4
  43. package/dist/controller/action-timeout.d.ts +9 -0
  44. package/dist/controller/action-timeout.js +95 -0
  45. package/dist/controller/registry/service.d.ts +1 -0
  46. package/dist/controller/registry/service.js +28 -1
  47. package/dist/controller/service.d.ts +2 -1
  48. package/dist/controller/service.js +494 -329
  49. package/dist/entrypoint.d.ts +1 -0
  50. package/dist/entrypoint.js +27 -0
  51. package/dist/filesystem/file-system.js +38 -8
  52. package/dist/integrations/gmail/service.js +30 -6
  53. package/dist/llm/browser-use/chat.js +2 -2
  54. package/dist/llm/codex/auth.d.ts +118 -0
  55. package/dist/llm/codex/auth.js +599 -0
  56. package/dist/llm/codex/chat.d.ts +70 -0
  57. package/dist/llm/codex/chat.js +392 -0
  58. package/dist/llm/codex/index.d.ts +2 -0
  59. package/dist/llm/codex/index.js +2 -0
  60. package/dist/llm/google/chat.js +18 -1
  61. package/dist/logging-config.js +22 -11
  62. package/dist/mcp/client.d.ts +1 -0
  63. package/dist/mcp/client.js +12 -10
  64. package/dist/mcp/redaction.d.ts +3 -0
  65. package/dist/mcp/redaction.js +132 -0
  66. package/dist/mcp/server.d.ts +2 -0
  67. package/dist/mcp/server.js +64 -22
  68. package/dist/screenshots/service.js +25 -2
  69. package/dist/skill-cli/direct.d.ts +4 -1
  70. package/dist/skill-cli/direct.js +263 -66
  71. package/dist/skill-cli/server.d.ts +1 -0
  72. package/dist/skill-cli/server.js +115 -25
  73. package/dist/skill-cli/tunnel.d.ts +1 -0
  74. package/dist/skill-cli/tunnel.js +16 -4
  75. package/dist/sync/auth.js +22 -9
  76. package/dist/telemetry/service.js +21 -2
  77. package/dist/telemetry/views.js +31 -8
  78. package/dist/tokens/custom-pricing.js +2 -2
  79. package/dist/tokens/openrouter-pricing.d.ts +11 -0
  80. package/dist/tokens/openrouter-pricing.js +102 -0
  81. package/dist/tokens/service.js +20 -16
  82. package/dist/utils.d.ts +3 -1
  83. package/dist/utils.js +3 -1
  84. package/package.json +68 -27
@@ -35,9 +35,48 @@ const execFileAsync = promisify(execFile);
35
35
  const PLAYWRIGHT_OPTION_KEY_OVERRIDES = {
36
36
  extra_http_headers: 'extraHTTPHeaders',
37
37
  };
38
+ const DOMAIN_POLICY_UNFILTERABLE_RECORDING_KEYS = new Set([
39
+ 'record_video_dir',
40
+ 'record_video_size',
41
+ 'record_video',
42
+ 'recordVideo',
43
+ ]);
38
44
  const EMPTY_DOM_RETRY_DELAY_MS = 250;
39
45
  const REMOTE_RECONNECT_DELAYS_MS = [1000, 2000, 4000];
40
46
  const REMOTE_RECONNECT_ATTEMPT_TIMEOUT_MS = 15_000;
47
+ const chmodPrivateStorageFile = (filePath) => {
48
+ if (process.platform === 'win32') {
49
+ return;
50
+ }
51
+ fs.chmodSync(filePath, 0o600);
52
+ };
53
+ const writePrivateStorageFile = (filePath, contents) => {
54
+ fs.writeFileSync(filePath, contents, { encoding: 'utf-8', mode: 0o600 });
55
+ chmodPrivateStorageFile(filePath);
56
+ };
57
+ const chmodPrivateFileBestEffort = (filePath) => {
58
+ if (process.platform === 'win32') {
59
+ return;
60
+ }
61
+ try {
62
+ fs.chmodSync(filePath, 0o600);
63
+ }
64
+ catch {
65
+ /* best effort */
66
+ }
67
+ };
68
+ const ensurePrivateDirectoryIfCreated = (dirPath) => {
69
+ const existed = fs.existsSync(dirPath);
70
+ fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
71
+ if (!existed && process.platform !== 'win32') {
72
+ try {
73
+ fs.chmodSync(dirPath, 0o700);
74
+ }
75
+ catch {
76
+ /* best effort */
77
+ }
78
+ }
79
+ };
41
80
  const cloneBrowserProfileConfig = (profile) => typeof structuredClone === 'function'
42
81
  ? structuredClone(profile.config)
43
82
  : JSON.parse(JSON.stringify(profile.config));
@@ -220,6 +259,8 @@ export class BrowserSession {
220
259
  _watchdogs = new Set();
221
260
  _defaultWatchdogsAttached = false;
222
261
  _captchaWatchdog = null;
262
+ _scopedExtraHeadersRouteContext = null;
263
+ _scopedExtraHeadersRouteHandler = null;
223
264
  RECONNECT_WAIT_TIMEOUT = 54;
224
265
  _reconnecting = false;
225
266
  _reconnectTask = null;
@@ -548,7 +589,10 @@ export class BrowserSession {
548
589
  // Check response size (if available)
549
590
  const contentLength = headers['content-length'] || headers['Content-Length'];
550
591
  if (contentLength && parseInt(contentLength, 10) > maxResponseSize) {
551
- this.logger.debug(`Skipping large response (${contentLength} bytes): ${request.url?.().substring?.(0, 50) ?? ''}`);
592
+ const requestUrl = typeof request?.url === 'function'
593
+ ? request.url()
594
+ : String(request?.url ?? '');
595
+ this.logger.debug(`Skipping large response (${contentLength} bytes): ${BrowserSession._redact_url_for_logging(requestUrl)}`);
552
596
  pendingRequests.delete(request);
553
597
  return;
554
598
  }
@@ -737,11 +781,11 @@ export class BrowserSession {
737
781
  timestamp: new Date().toISOString(),
738
782
  };
739
783
  if (typeof details.url === 'string' && details.url.trim()) {
740
- event.url = details.url.trim();
784
+ event.url = BrowserSession._redact_url_for_logging(details.url.trim());
741
785
  }
742
786
  if (typeof details.error_message === 'string' &&
743
787
  details.error_message.trim()) {
744
- event.error_message = details.error_message.trim();
788
+ event.error_message = BrowserSession._redact_urls_in_text(details.error_message.trim());
745
789
  }
746
790
  if (typeof details.page_id === 'number' &&
747
791
  Number.isFinite(details.page_id)) {
@@ -813,6 +857,7 @@ export class BrowserSession {
813
857
  if (!page || typeof page.evaluate !== 'function') {
814
858
  return [];
815
859
  }
860
+ await this.validate_page_after_action(page);
816
861
  try {
817
862
  const pending = await page.evaluate(() => {
818
863
  const perf = window.performance;
@@ -897,6 +942,9 @@ export class BrowserSession {
897
942
  this.logger.debug(`Failed to gather pending network requests: ${error.message}`);
898
943
  return [];
899
944
  }
945
+ finally {
946
+ await this.validate_page_after_action(page);
947
+ }
900
948
  }
901
949
  get tabs() {
902
950
  return this._tabs.slice();
@@ -1156,6 +1204,21 @@ export class BrowserSession {
1156
1204
  }
1157
1205
  const result = {};
1158
1206
  for (const [rawKey, rawVal] of Object.entries(value)) {
1207
+ if (rawKey === 'extra_http_headers' &&
1208
+ this._has_url_access_restrictions()) {
1209
+ continue;
1210
+ }
1211
+ if (rawKey === 'permissions' && this._has_url_access_restrictions()) {
1212
+ continue;
1213
+ }
1214
+ if (DOMAIN_POLICY_UNFILTERABLE_RECORDING_KEYS.has(rawKey) &&
1215
+ this._has_url_access_restrictions()) {
1216
+ continue;
1217
+ }
1218
+ if (rawKey === 'http_credentials' &&
1219
+ this._has_url_access_restrictions()) {
1220
+ this._assertHttpCredentialsScoped(rawVal);
1221
+ }
1159
1222
  const convertedValue = this._toPlaywrightOptions(rawVal);
1160
1223
  if (convertedValue === undefined) {
1161
1224
  continue;
@@ -1166,6 +1229,16 @@ export class BrowserSession {
1166
1229
  }
1167
1230
  return result;
1168
1231
  }
1232
+ _assertHttpCredentialsScoped(value) {
1233
+ if (!value || typeof value !== 'object') {
1234
+ return;
1235
+ }
1236
+ const origin = value.origin;
1237
+ if (typeof origin !== 'string' || origin.trim().length === 0) {
1238
+ throw new BrowserError('http_credentials must include an origin when domain restrictions are configured.');
1239
+ }
1240
+ this._assert_url_allowed(origin.trim());
1241
+ }
1169
1242
  async set_extra_headers(headers) {
1170
1243
  const normalizedHeaders = Object.fromEntries(Object.entries(headers)
1171
1244
  .map(([key, value]) => [String(key).trim(), String(value)])
@@ -1175,8 +1248,78 @@ export class BrowserSession {
1175
1248
  typeof this.browser_context.setExtraHTTPHeaders !== 'function') {
1176
1249
  return;
1177
1250
  }
1251
+ if (this._has_url_access_restrictions()) {
1252
+ await this._installScopedExtraHeaders(normalizedHeaders);
1253
+ return;
1254
+ }
1255
+ await this._removeScopedExtraHeadersRoute();
1178
1256
  await this.browser_context.setExtraHTTPHeaders(normalizedHeaders);
1179
1257
  }
1258
+ async _removeScopedExtraHeadersRoute() {
1259
+ const context = this._scopedExtraHeadersRouteContext;
1260
+ const handler = this._scopedExtraHeadersRouteHandler;
1261
+ this._scopedExtraHeadersRouteContext = null;
1262
+ this._scopedExtraHeadersRouteHandler = null;
1263
+ if (!context || !handler || typeof context.unroute !== 'function') {
1264
+ return;
1265
+ }
1266
+ try {
1267
+ await context.unroute('**/*', handler);
1268
+ }
1269
+ catch (error) {
1270
+ this.logger.debug(`Failed to remove scoped extra_http_headers route: ${error.message}`);
1271
+ }
1272
+ }
1273
+ async _installScopedExtraHeaders(headers) {
1274
+ const context = this.browser_context;
1275
+ if (!context || typeof context.route !== 'function') {
1276
+ throw new BrowserError('Cannot safely apply extra_http_headers with domain restrictions because BrowserContext.route is unavailable.');
1277
+ }
1278
+ await this._removeScopedExtraHeadersRoute();
1279
+ if (typeof context.setExtraHTTPHeaders === 'function') {
1280
+ await context.setExtraHTTPHeaders({});
1281
+ }
1282
+ const routeHandler = async (route) => {
1283
+ const continueRoute = async (overrides) => {
1284
+ if (typeof route?.fallback === 'function') {
1285
+ return await route.fallback(overrides);
1286
+ }
1287
+ if (typeof route?.continue === 'function') {
1288
+ return await route.continue(overrides);
1289
+ }
1290
+ throw new BrowserError('Cannot continue routed request while applying scoped extra_http_headers.');
1291
+ };
1292
+ const request = typeof route?.request === 'function' ? route.request() : null;
1293
+ const url = typeof request?.url === 'function'
1294
+ ? String(request.url())
1295
+ : typeof request?.url === 'string'
1296
+ ? request.url
1297
+ : '';
1298
+ let denialReason = 'invalid_url';
1299
+ if (url) {
1300
+ try {
1301
+ denialReason = this._get_url_access_denial_reason(url);
1302
+ }
1303
+ catch {
1304
+ denialReason = 'blocked';
1305
+ }
1306
+ }
1307
+ if (denialReason) {
1308
+ await continueRoute();
1309
+ return;
1310
+ }
1311
+ const requestHeaders = typeof request?.headers === 'function' ? request.headers() : {};
1312
+ await continueRoute({
1313
+ headers: {
1314
+ ...(requestHeaders ?? {}),
1315
+ ...headers,
1316
+ },
1317
+ });
1318
+ };
1319
+ await context.route('**/*', routeHandler);
1320
+ this._scopedExtraHeadersRouteContext = context;
1321
+ this._scopedExtraHeadersRouteHandler = routeHandler;
1322
+ }
1180
1323
  async _applyConfiguredExtraHttpHeaders() {
1181
1324
  const configuredHeaders = this.browser_profile.config.extra_http_headers;
1182
1325
  if (!configuredHeaders || Object.keys(configuredHeaders).length === 0) {
@@ -1325,6 +1468,9 @@ export class BrowserSession {
1325
1468
  }
1326
1469
  this._setActivePage(nextPage);
1327
1470
  this.human_current_page = nextPage;
1471
+ if (nextPage) {
1472
+ await this._assert_page_url_allowed_or_rollback(nextPage);
1473
+ }
1328
1474
  await this._syncCurrentTabFromPage(nextPage);
1329
1475
  }
1330
1476
  async reconnect(options = {}) {
@@ -1557,6 +1703,8 @@ export class BrowserSession {
1557
1703
  }
1558
1704
  const activePage = await this.get_current_page();
1559
1705
  if (activePage) {
1706
+ await this._assert_page_url_allowed_or_rollback(activePage);
1707
+ await this._syncCurrentTabFromPage(activePage);
1560
1708
  try {
1561
1709
  this.currentUrl = normalize_url(activePage.url());
1562
1710
  }
@@ -1598,7 +1746,7 @@ export class BrowserSession {
1598
1746
  throw new Error(`Could not discover CDP URL for browser PID ${browserPid}`);
1599
1747
  }
1600
1748
  this.cdp_url = cdpUrl;
1601
- this.logger.info(`Discovered CDP URL: ${cdpUrl}`);
1749
+ this.logger.info(`Discovered CDP URL: ${BrowserSession._redact_url_for_logging(cdpUrl)}`);
1602
1750
  // Connect to browser via CDP
1603
1751
  try {
1604
1752
  const playwright = await import('playwright');
@@ -1744,6 +1892,7 @@ export class BrowserSession {
1744
1892
  domState = createEmptyDomState();
1745
1893
  }
1746
1894
  else {
1895
+ await this.validate_page_after_action(page, signal);
1747
1896
  try {
1748
1897
  const domService = new DomService(page, this.logger);
1749
1898
  domState = await this._withAbort(domService.get_clickable_elements(this.browser_profile.highlight_elements, -1, this.browser_profile.viewport_expansion), signal);
@@ -1762,7 +1911,7 @@ export class BrowserSession {
1762
1911
  !this._is_new_tab_page(liveUrl) &&
1763
1912
  !liveUrl.toLowerCase().endsWith('.pdf');
1764
1913
  if (shouldRetryEmptyDom) {
1765
- this.logger.debug(`Empty DOM detected for ${liveUrl}; retrying once`);
1914
+ this.logger.debug(`Empty DOM detected for ${BrowserSession._redact_url_for_logging(liveUrl)}; retrying once`);
1766
1915
  await this._waitWithAbort(EMPTY_DOM_RETRY_DELAY_MS, signal);
1767
1916
  try {
1768
1917
  const retryDomService = new DomService(page, this.logger);
@@ -1778,6 +1927,7 @@ export class BrowserSession {
1778
1927
  this.logger.debug(`Retry after empty DOM failed: ${error.message}`);
1779
1928
  }
1780
1929
  }
1930
+ await this.validate_page_after_action(page, signal);
1781
1931
  }
1782
1932
  let screenshot = null;
1783
1933
  if (options.include_screenshot && page?.screenshot) {
@@ -1797,6 +1947,7 @@ export class BrowserSession {
1797
1947
  }
1798
1948
  this.logger.debug(`Failed to capture screenshot: ${error.message}`);
1799
1949
  }
1950
+ await this.validate_page_after_action(page, signal);
1800
1951
  }
1801
1952
  let pageInfo = null;
1802
1953
  let pixelsAbove = 0;
@@ -1842,10 +1993,11 @@ export class BrowserSession {
1842
1993
  }
1843
1994
  this.logger.debug(`Failed to compute page metrics: ${error.message}`);
1844
1995
  }
1996
+ await this.validate_page_after_action(page, signal);
1845
1997
  }
1846
1998
  const pendingNetworkRequests = await this._getPendingNetworkRequests(page);
1847
1999
  if (page) {
1848
- await this._syncCurrentTabFromPage(page);
2000
+ await this.validate_page_after_action(page, signal);
1849
2001
  }
1850
2002
  if (pageInfo &&
1851
2003
  Number.isFinite(pageInfo.viewport_width) &&
@@ -1878,6 +2030,7 @@ export class BrowserSession {
1878
2030
  });
1879
2031
  // Implement clickable element hash caching to detect new elements
1880
2032
  if (options.cache_clickable_elements_hashes && page) {
2033
+ await this.validate_page_after_action(page, signal);
1881
2034
  const currentUrl = page.url();
1882
2035
  const currentHashes = this._computeElementHashes(domState.selector_map);
1883
2036
  // Mark new elements if we have cached hashes for this URL
@@ -1892,6 +2045,9 @@ export class BrowserSession {
1892
2045
  };
1893
2046
  }
1894
2047
  this._throwIfAborted(signal);
2048
+ if (page) {
2049
+ await this.validate_page_after_action(page, signal);
2050
+ }
1895
2051
  this.cachedBrowserState = summary;
1896
2052
  return summary;
1897
2053
  }
@@ -1940,7 +2096,7 @@ export class BrowserSession {
1940
2096
  tab.title = this.currentTitle || this.currentUrl;
1941
2097
  }
1942
2098
  this._syncSessionManagerFromTabs();
1943
- return this._tabs.slice();
2099
+ return this._tabs.map((tab) => this._sanitize_tab_for_exposure(tab));
1944
2100
  }
1945
2101
  async navigate_to(url, options = {}) {
1946
2102
  const signal = options.signal ?? null;
@@ -1966,14 +2122,44 @@ export class BrowserSession {
1966
2122
  }
1967
2123
  await this._withAbort(page.goto(normalized, gotoOptions), signal);
1968
2124
  const finalUrl = page.url();
1969
- this._assert_url_allowed(finalUrl);
2125
+ try {
2126
+ this._assert_url_allowed(finalUrl);
2127
+ }
2128
+ catch (error) {
2129
+ if (error instanceof URLNotAllowedError) {
2130
+ await this._rollback_disallowed_navigation(page, finalUrl);
2131
+ }
2132
+ throw error;
2133
+ }
1970
2134
  completedUrl = normalize_url(finalUrl);
1971
2135
  await this._waitForStableNetwork(page, signal);
2136
+ const settledUrl = page.url();
2137
+ try {
2138
+ this._assert_url_allowed(settledUrl);
2139
+ }
2140
+ catch (error) {
2141
+ if (error instanceof URLNotAllowedError) {
2142
+ await this._rollback_disallowed_navigation(page, settledUrl);
2143
+ }
2144
+ throw error;
2145
+ }
2146
+ completedUrl = normalize_url(settledUrl);
1972
2147
  }
1973
2148
  catch (error) {
1974
2149
  if (this._isAbortError(error)) {
2150
+ const disallowedPageError = await this._get_disallowed_page_error_after_navigation_error(page);
2151
+ if (disallowedPageError) {
2152
+ throw disallowedPageError;
2153
+ }
2154
+ throw error;
2155
+ }
2156
+ if (error instanceof URLNotAllowedError) {
1975
2157
  throw error;
1976
2158
  }
2159
+ const disallowedPageError = await this._get_disallowed_page_error_after_navigation_error(page);
2160
+ if (disallowedPageError) {
2161
+ throw disallowedPageError;
2162
+ }
1977
2163
  const message = error.message ?? 'Navigation failed';
1978
2164
  this._recordRecentEvent('navigation_failed', {
1979
2165
  url: normalized,
@@ -2044,13 +2230,22 @@ export class BrowserSession {
2044
2230
  this._assert_url_allowed(finalUrl);
2045
2231
  completedUrl = normalize_url(finalUrl);
2046
2232
  await this._waitForStableNetwork(page, signal);
2233
+ const settledUrl = page.url();
2234
+ this._assert_url_allowed(settledUrl);
2235
+ completedUrl = normalize_url(settledUrl);
2047
2236
  }
2048
2237
  }
2049
2238
  catch (error) {
2050
- if (this._isAbortError(error)) {
2051
- throw error;
2052
- }
2053
- const message = error.message ?? 'Failed to open new tab';
2239
+ const isAbortError = this._isAbortError(error);
2240
+ let finalError = error;
2241
+ if (!(finalError instanceof URLNotAllowedError)) {
2242
+ finalError =
2243
+ (await this._get_disallowed_page_error_after_navigation_error(page, {
2244
+ replace_on_failure: false,
2245
+ })) ?? finalError;
2246
+ }
2247
+ const isUrlNotAllowed = finalError instanceof URLNotAllowedError;
2248
+ const message = finalError.message ?? 'Failed to open new tab';
2054
2249
  this._recordRecentEvent('tab_navigation_failed', {
2055
2250
  url: normalized,
2056
2251
  page_id: newTab.page_id,
@@ -2094,6 +2289,12 @@ export class BrowserSession {
2094
2289
  }
2095
2290
  this._syncSessionManagerFromTabs();
2096
2291
  this.cachedBrowserState = null;
2292
+ if (isUrlNotAllowed) {
2293
+ throw finalError;
2294
+ }
2295
+ if (isAbortError) {
2296
+ throw finalError;
2297
+ }
2097
2298
  throw new BrowserError(message);
2098
2299
  }
2099
2300
  this.tabPages.set(newTab.page_id, page);
@@ -2195,14 +2396,18 @@ export class BrowserSession {
2195
2396
  }
2196
2397
  }
2197
2398
  await this._waitForLoad(page, 5000, signal);
2399
+ if (page) {
2400
+ await this._assert_page_url_allowed_or_rollback(page);
2401
+ await this._syncCurrentTabFromPage(page);
2402
+ }
2198
2403
  this._recordRecentEvent('tab_switched', {
2199
- url: tab.url,
2404
+ url: this.active_tab?.url ?? tab.url,
2200
2405
  page_id: tab.page_id,
2201
2406
  tab_id: tab.tab_id,
2202
2407
  });
2203
2408
  await this.event_bus.dispatch(new AgentFocusChangedEvent({
2204
2409
  target_id: tab.target_id ?? tab.tab_id,
2205
- url: tab.url,
2410
+ url: this.active_tab?.url ?? tab.url,
2206
2411
  }));
2207
2412
  this.cachedBrowserState = null;
2208
2413
  return page;
@@ -2268,39 +2473,62 @@ export class BrowserSession {
2268
2473
  if (delayMs <= 0) {
2269
2474
  return;
2270
2475
  }
2271
- await this._waitWithAbort(delayMs, signal);
2476
+ const page = await this.get_current_page();
2477
+ try {
2478
+ await this._waitWithAbort(delayMs, signal);
2479
+ }
2480
+ finally {
2481
+ await this.validate_page_after_action(page, signal);
2482
+ }
2272
2483
  }
2273
2484
  async send_keys(keys, options = {}) {
2274
2485
  const signal = options.signal ?? null;
2275
2486
  this._throwIfAborted(signal);
2276
2487
  const page = await this._withAbort(this.get_current_page(), signal);
2488
+ await this.validate_page_after_action(page, signal);
2277
2489
  const keyboard = page?.keyboard;
2278
2490
  if (!keyboard) {
2279
2491
  throw new BrowserError('Keyboard input is not available on the current page.');
2280
2492
  }
2281
2493
  try {
2282
- await this._withAbort(keyboard.press(keys), signal);
2283
- }
2284
- catch (error) {
2285
- if (error instanceof Error && error.message.includes('Unknown key')) {
2286
- for (const char of keys) {
2287
- await this._withAbort(keyboard.press(char), signal);
2494
+ try {
2495
+ await this._withAbort(keyboard.press(keys), signal);
2496
+ }
2497
+ catch (error) {
2498
+ if (error instanceof Error && error.message.includes('Unknown key')) {
2499
+ for (const char of keys) {
2500
+ await this._withAbort(keyboard.press(char), signal);
2501
+ }
2502
+ return;
2288
2503
  }
2289
- return;
2504
+ throw error;
2290
2505
  }
2291
- throw error;
2506
+ }
2507
+ finally {
2508
+ await this._waitForLoad(page, 5000, signal);
2509
+ if (page) {
2510
+ await this._assert_page_url_allowed_or_rollback(page);
2511
+ await this._syncCurrentTabFromPage(page);
2512
+ }
2513
+ this.cachedBrowserState = null;
2292
2514
  }
2293
2515
  }
2294
2516
  async click_coordinates(coordinate_x, coordinate_y, options = {}) {
2295
2517
  const signal = options.signal ?? null;
2296
2518
  this._throwIfAborted(signal);
2297
2519
  const page = await this._withAbort(this.get_current_page(), signal);
2520
+ await this.validate_page_after_action(page, signal);
2298
2521
  if (!page?.mouse?.click) {
2299
2522
  throw new BrowserError('Unable to perform coordinate click on the current page.');
2300
2523
  }
2301
- await this._withAbort(page.mouse.click(coordinate_x, coordinate_y, {
2302
- button: options.button ?? 'left',
2303
- }), signal);
2524
+ try {
2525
+ await this._withAbort(page.mouse.click(coordinate_x, coordinate_y, {
2526
+ button: options.button ?? 'left',
2527
+ }), signal);
2528
+ }
2529
+ finally {
2530
+ await this.validate_page_after_action(page, signal);
2531
+ }
2304
2532
  }
2305
2533
  async scroll(direction, amount, options = {}) {
2306
2534
  const signal = options.signal ?? null;
@@ -2310,110 +2538,129 @@ export class BrowserSession {
2310
2538
  return;
2311
2539
  }
2312
2540
  const page = await this._withAbort(this.get_current_page(), signal);
2541
+ await this.validate_page_after_action(page, signal);
2313
2542
  if (!page?.evaluate) {
2314
2543
  throw new BrowserError('Unable to access current page for scrolling.');
2315
2544
  }
2316
- const node = options.node ?? null;
2317
- if (node?.xpath) {
2318
- const scrolled = await this._withAbort(page.evaluate((payload) => {
2319
- const root = document.evaluate(payload.xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
2320
- if (!root) {
2321
- return false;
2545
+ try {
2546
+ const node = options.node ?? null;
2547
+ if (node?.xpath) {
2548
+ const scrolled = await this._withAbort(page.evaluate((payload) => {
2549
+ const root = document.evaluate(payload.xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
2550
+ if (!root) {
2551
+ return false;
2552
+ }
2553
+ const topDelta = payload.direction === 'up'
2554
+ ? -payload.amount
2555
+ : payload.direction === 'down'
2556
+ ? payload.amount
2557
+ : 0;
2558
+ const leftDelta = payload.direction === 'left'
2559
+ ? -payload.amount
2560
+ : payload.direction === 'right'
2561
+ ? payload.amount
2562
+ : 0;
2563
+ root.scrollBy({
2564
+ top: topDelta,
2565
+ left: leftDelta,
2566
+ behavior: 'auto',
2567
+ });
2568
+ return true;
2569
+ }, { xpath: node.xpath, direction, amount: normalizedAmount }), signal);
2570
+ if (scrolled) {
2571
+ return;
2322
2572
  }
2323
- const topDelta = payload.direction === 'up'
2324
- ? -payload.amount
2325
- : payload.direction === 'down'
2326
- ? payload.amount
2327
- : 0;
2328
- const leftDelta = payload.direction === 'left'
2329
- ? -payload.amount
2330
- : payload.direction === 'right'
2331
- ? payload.amount
2332
- : 0;
2333
- root.scrollBy({
2334
- top: topDelta,
2335
- left: leftDelta,
2336
- behavior: 'auto',
2337
- });
2338
- return true;
2339
- }, { xpath: node.xpath, direction, amount: normalizedAmount }), signal);
2340
- if (scrolled) {
2573
+ }
2574
+ if (direction === 'up' || direction === 'down') {
2575
+ const pixels = direction === 'down' ? -normalizedAmount : normalizedAmount;
2576
+ await this._withAbort(this._scrollContainer(pixels), signal);
2341
2577
  return;
2342
2578
  }
2579
+ const horizontalDelta = direction === 'left' ? -normalizedAmount : normalizedAmount;
2580
+ await this._withAbort(page.evaluate((x) => window.scrollBy(x, 0), horizontalDelta), signal);
2343
2581
  }
2344
- if (direction === 'up' || direction === 'down') {
2345
- const pixels = direction === 'down' ? -normalizedAmount : normalizedAmount;
2346
- await this._withAbort(this._scrollContainer(pixels), signal);
2347
- return;
2582
+ finally {
2583
+ await this.validate_page_after_action(page, signal);
2348
2584
  }
2349
- const horizontalDelta = direction === 'left' ? -normalizedAmount : normalizedAmount;
2350
- await this._withAbort(page.evaluate((x) => window.scrollBy(x, 0), horizontalDelta), signal);
2351
2585
  }
2352
2586
  async scroll_to_text(text, options = {}) {
2353
2587
  const signal = options.signal ?? null;
2354
2588
  this._throwIfAborted(signal);
2355
2589
  const page = await this._withAbort(this.get_current_page(), signal);
2590
+ await this.validate_page_after_action(page, signal);
2356
2591
  if (!page?.evaluate) {
2357
2592
  throw new BrowserError('Unable to access page for scrolling.');
2358
2593
  }
2359
- const success = await this._withAbort(page.evaluate((payload) => {
2360
- const query = payload.text.toLowerCase();
2361
- const iterator = document.createNodeIterator(document.body, NodeFilter.SHOW_ELEMENT);
2362
- let node;
2363
- while ((node = iterator.nextNode())) {
2364
- const el = node;
2365
- if (!el || !el.textContent) {
2366
- continue;
2367
- }
2368
- if (el.textContent.toLowerCase().includes(query)) {
2369
- el.scrollIntoView({
2370
- behavior: 'smooth',
2371
- block: payload.direction === 'up' ? 'start' : 'center',
2372
- });
2373
- return true;
2594
+ try {
2595
+ const success = await this._withAbort(page.evaluate((payload) => {
2596
+ const query = payload.text.toLowerCase();
2597
+ const iterator = document.createNodeIterator(document.body, NodeFilter.SHOW_ELEMENT);
2598
+ let node;
2599
+ while ((node = iterator.nextNode())) {
2600
+ const el = node;
2601
+ if (!el || !el.textContent) {
2602
+ continue;
2603
+ }
2604
+ if (el.textContent.toLowerCase().includes(query)) {
2605
+ el.scrollIntoView({
2606
+ behavior: 'smooth',
2607
+ block: payload.direction === 'up' ? 'start' : 'center',
2608
+ });
2609
+ return true;
2610
+ }
2374
2611
  }
2612
+ return false;
2613
+ }, { text, direction: options.direction ?? 'down' }), signal);
2614
+ if (!success) {
2615
+ throw new BrowserError(`Text '${text}' not found on page`);
2375
2616
  }
2376
- return false;
2377
- }, { text, direction: options.direction ?? 'down' }), signal);
2378
- if (!success) {
2379
- throw new BrowserError(`Text '${text}' not found on page`);
2617
+ }
2618
+ finally {
2619
+ await this.validate_page_after_action(page, signal);
2380
2620
  }
2381
2621
  }
2382
2622
  async get_dropdown_options(element_node, options = {}) {
2383
2623
  const signal = options.signal ?? null;
2384
2624
  this._throwIfAborted(signal);
2385
2625
  const page = await this._withAbort(this.get_current_page(), signal);
2626
+ await this.validate_page_after_action(page, signal);
2386
2627
  if (!page?.evaluate) {
2387
2628
  throw new BrowserError('Unable to evaluate dropdown options on current page.');
2388
2629
  }
2389
2630
  if (!element_node?.xpath) {
2390
2631
  throw new BrowserError('DOM element does not include an XPath selector.');
2391
2632
  }
2392
- const payload = await this._withAbort(page.evaluate(({ xpath }) => {
2393
- const element = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
2394
- if (!element)
2633
+ let payload;
2634
+ try {
2635
+ payload = await this._withAbort(page.evaluate(({ xpath }) => {
2636
+ const element = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
2637
+ if (!element)
2638
+ return null;
2639
+ if (element.tagName?.toLowerCase() === 'select') {
2640
+ const options = Array.from(element.options).map((opt, index) => ({
2641
+ text: opt.textContent?.trim() ?? '',
2642
+ value: (opt.value ?? '').trim(),
2643
+ index,
2644
+ }));
2645
+ return { type: 'select', options };
2646
+ }
2647
+ const ariaRoles = new Set(['menu', 'listbox', 'combobox']);
2648
+ const role = element.getAttribute('role');
2649
+ if (role && ariaRoles.has(role)) {
2650
+ const nodes = element.querySelectorAll('[role="menuitem"],[role="option"]');
2651
+ const options = Array.from(nodes).map((node, index) => ({
2652
+ text: node.textContent?.trim() ?? '',
2653
+ value: node.textContent?.trim() ?? '',
2654
+ index,
2655
+ }));
2656
+ return { type: 'aria', options };
2657
+ }
2395
2658
  return null;
2396
- if (element.tagName?.toLowerCase() === 'select') {
2397
- const options = Array.from(element.options).map((opt, index) => ({
2398
- text: opt.textContent?.trim() ?? '',
2399
- value: (opt.value ?? '').trim(),
2400
- index,
2401
- }));
2402
- return { type: 'select', options };
2403
- }
2404
- const ariaRoles = new Set(['menu', 'listbox', 'combobox']);
2405
- const role = element.getAttribute('role');
2406
- if (role && ariaRoles.has(role)) {
2407
- const nodes = element.querySelectorAll('[role="menuitem"],[role="option"]');
2408
- const options = Array.from(nodes).map((node, index) => ({
2409
- text: node.textContent?.trim() ?? '',
2410
- value: node.textContent?.trim() ?? '',
2411
- index,
2412
- }));
2413
- return { type: 'aria', options };
2414
- }
2415
- return null;
2416
- }, { xpath: element_node.xpath }), signal);
2659
+ }, { xpath: element_node.xpath }), signal);
2660
+ }
2661
+ finally {
2662
+ await this.validate_page_after_action(page, signal);
2663
+ }
2417
2664
  if (!payload || !Array.isArray(payload.options)) {
2418
2665
  throw new BrowserError('No options found for the specified dropdown.');
2419
2666
  }
@@ -2452,49 +2699,117 @@ export class BrowserSession {
2452
2699
  if (!page) {
2453
2700
  throw new BrowserError('No active page for selection.');
2454
2701
  }
2455
- const formatAvailableOptions = (opts) => opts
2456
- .map((opt) => ` - [${opt.index}] text=${JSON.stringify(opt.text)} value=${JSON.stringify(opt.value)}`)
2457
- .join('\n');
2458
- const pageFrames = (() => {
2459
- const framesAccessor = page.frames;
2460
- if (typeof framesAccessor === 'function') {
2461
- try {
2462
- const result = framesAccessor.call(page);
2463
- return Array.isArray(result) ? result : [];
2464
- }
2465
- catch {
2466
- return [];
2467
- }
2468
- }
2469
- return Array.isArray(framesAccessor) ? framesAccessor : [];
2470
- })();
2471
- for (const frame of pageFrames) {
2472
- try {
2473
- const typeInfo = await this._withAbort(frame.evaluate((xpath) => {
2474
- const element = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
2475
- if (!element)
2476
- return { found: false };
2477
- const tagName = element.tagName?.toLowerCase();
2478
- const role = element.getAttribute?.('role');
2479
- if (tagName === 'select')
2480
- return { found: true, type: 'select' };
2481
- if (role && ['menu', 'listbox', 'combobox'].includes(role))
2482
- return { found: true, type: 'aria' };
2483
- return { found: false };
2484
- }, element_node.xpath), signal);
2485
- if (!typeInfo?.found) {
2486
- continue;
2702
+ await this.validate_page_after_action(page, signal);
2703
+ try {
2704
+ const formatAvailableOptions = (opts) => opts
2705
+ .map((opt) => ` - [${opt.index}] text=${JSON.stringify(opt.text)} value=${JSON.stringify(opt.value)}`)
2706
+ .join('\n');
2707
+ const pageFrames = (() => {
2708
+ const framesAccessor = page.frames;
2709
+ if (typeof framesAccessor === 'function') {
2710
+ try {
2711
+ const result = framesAccessor.call(page);
2712
+ return Array.isArray(result) ? result : [];
2713
+ }
2714
+ catch {
2715
+ return [];
2716
+ }
2487
2717
  }
2488
- if (typeInfo.type === 'select') {
2489
- const selection = await this._withAbort(frame.evaluate(({ xpath, optionText, }) => {
2490
- const root = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
2491
- if (!root || root.tagName?.toLowerCase() !== 'select') {
2718
+ return Array.isArray(framesAccessor) ? framesAccessor : [];
2719
+ })();
2720
+ for (const frame of pageFrames) {
2721
+ try {
2722
+ const typeInfo = await this._withAbort(frame.evaluate((xpath) => {
2723
+ const element = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
2724
+ if (!element)
2492
2725
  return { found: false };
2726
+ const tagName = element.tagName?.toLowerCase();
2727
+ const role = element.getAttribute?.('role');
2728
+ if (tagName === 'select')
2729
+ return { found: true, type: 'select' };
2730
+ if (role && ['menu', 'listbox', 'combobox'].includes(role))
2731
+ return { found: true, type: 'aria' };
2732
+ return { found: false };
2733
+ }, element_node.xpath), signal);
2734
+ if (!typeInfo?.found) {
2735
+ continue;
2736
+ }
2737
+ if (typeInfo.type === 'select') {
2738
+ const selection = await this._withAbort(frame.evaluate(({ xpath, optionText, }) => {
2739
+ const root = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
2740
+ if (!root || root.tagName?.toLowerCase() !== 'select') {
2741
+ return { found: false };
2742
+ }
2743
+ const options = Array.from(root.options).map((opt, index) => ({
2744
+ index,
2745
+ text: opt.textContent?.trim() ?? '',
2746
+ value: (opt.value ?? '').trim(),
2747
+ }));
2748
+ const targetRaw = optionText.trim();
2749
+ const targetLower = optionText.trim().toLowerCase();
2750
+ let matchedIndex = options.findIndex((opt) => opt.text === targetRaw || opt.value === targetRaw);
2751
+ if (matchedIndex < 0) {
2752
+ matchedIndex = options.findIndex((opt) => opt.text.trim().toLowerCase() === targetLower ||
2753
+ opt.value.trim().toLowerCase() === targetLower);
2754
+ }
2755
+ if (matchedIndex < 0) {
2756
+ return { found: true, success: false, options };
2757
+ }
2758
+ const matched = options[matchedIndex];
2759
+ root.value = matched.value;
2760
+ root.dispatchEvent(new Event('input', { bubbles: true }));
2761
+ root.dispatchEvent(new Event('change', { bubbles: true }));
2762
+ const selectedOption = root.selectedIndex >= 0
2763
+ ? root.options[root.selectedIndex]
2764
+ : null;
2765
+ const selectedText = selectedOption?.textContent?.trim() ?? '';
2766
+ const selectedValue = (root.value ?? '').trim();
2767
+ const selectedValueLower = selectedValue.trim().toLowerCase();
2768
+ const selectedTextLower = selectedText.trim().toLowerCase();
2769
+ const matchedValueLower = String(matched.value ?? '')
2770
+ .trim()
2771
+ .toLowerCase();
2772
+ const matchedTextLower = String(matched.text ?? '')
2773
+ .trim()
2774
+ .toLowerCase();
2775
+ const verified = selectedValueLower === matchedValueLower ||
2776
+ selectedTextLower === matchedTextLower;
2777
+ return {
2778
+ found: true,
2779
+ success: verified,
2780
+ options,
2781
+ selectedText,
2782
+ selectedValue,
2783
+ matched,
2784
+ };
2785
+ }, { xpath: element_node.xpath, optionText: text }), signal);
2786
+ if (selection?.found && selection.success) {
2787
+ const matchedText = selection.matched?.text ?? text;
2788
+ const matchedValue = selection.matched?.value ?? '';
2789
+ const msg = `Selected option ${matchedText} (${matchedValue})`;
2790
+ return {
2791
+ message: msg,
2792
+ short_term_memory: msg,
2793
+ long_term_memory: msg,
2794
+ matched_text: String(matchedText),
2795
+ matched_value: String(matchedValue),
2796
+ };
2493
2797
  }
2494
- const options = Array.from(root.options).map((opt, index) => ({
2798
+ if (selection?.found) {
2799
+ const details = formatAvailableOptions(selection.options ?? []);
2800
+ throw new BrowserError(`Could not select option '${text}' for index ${element_node.highlight_index ?? 'unknown'}.\nAvailable options:\n${details}`);
2801
+ }
2802
+ continue;
2803
+ }
2804
+ const clicked = await this._withAbort(frame.evaluate(({ xpath, optionText, }) => {
2805
+ const root = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
2806
+ if (!root)
2807
+ return false;
2808
+ const nodes = root.querySelectorAll('[role="menuitem"],[role="option"]');
2809
+ const options = Array.from(nodes).map((node, index) => ({
2495
2810
  index,
2496
- text: opt.textContent?.trim() ?? '',
2497
- value: (opt.value ?? '').trim(),
2811
+ text: node.textContent?.trim() ?? '',
2812
+ value: node.textContent?.trim() ?? '',
2498
2813
  }));
2499
2814
  const targetRaw = optionText.trim();
2500
2815
  const targetLower = optionText.trim().toLowerCase();
@@ -2506,103 +2821,41 @@ export class BrowserSession {
2506
2821
  if (matchedIndex < 0) {
2507
2822
  return { found: true, success: false, options };
2508
2823
  }
2509
- const matched = options[matchedIndex];
2510
- root.value = matched.value;
2511
- root.dispatchEvent(new Event('input', { bubbles: true }));
2512
- root.dispatchEvent(new Event('change', { bubbles: true }));
2513
- const selectedOption = root.selectedIndex >= 0
2514
- ? root.options[root.selectedIndex]
2515
- : null;
2516
- const selectedText = selectedOption?.textContent?.trim() ?? '';
2517
- const selectedValue = (root.value ?? '').trim();
2518
- const selectedValueLower = selectedValue.trim().toLowerCase();
2519
- const selectedTextLower = selectedText.trim().toLowerCase();
2520
- const matchedValueLower = String(matched.value ?? '')
2521
- .trim()
2522
- .toLowerCase();
2523
- const matchedTextLower = String(matched.text ?? '')
2524
- .trim()
2525
- .toLowerCase();
2526
- const verified = selectedValueLower === matchedValueLower ||
2527
- selectedTextLower === matchedTextLower;
2824
+ nodes[matchedIndex].click();
2528
2825
  return {
2529
2826
  found: true,
2530
- success: verified,
2827
+ success: true,
2531
2828
  options,
2532
- selectedText,
2533
- selectedValue,
2534
- matched,
2829
+ matched: options[matchedIndex],
2535
2830
  };
2536
2831
  }, { xpath: element_node.xpath, optionText: text }), signal);
2537
- if (selection?.found && selection.success) {
2538
- const matchedText = selection.matched?.text ?? text;
2539
- const matchedValue = selection.matched?.value ?? '';
2540
- const msg = `Selected option ${matchedText} (${matchedValue})`;
2832
+ if (clicked?.found && clicked.success) {
2833
+ const matchedText = clicked.matched?.text ?? text;
2834
+ const msg = `Selected menu item ${matchedText}`;
2541
2835
  return {
2542
2836
  message: msg,
2543
2837
  short_term_memory: msg,
2544
2838
  long_term_memory: msg,
2545
2839
  matched_text: String(matchedText),
2546
- matched_value: String(matchedValue),
2547
2840
  };
2548
2841
  }
2549
- if (selection?.found) {
2550
- const details = formatAvailableOptions(selection.options ?? []);
2842
+ if (clicked?.found) {
2843
+ const details = formatAvailableOptions(clicked.options ?? []);
2551
2844
  throw new BrowserError(`Could not select option '${text}' for index ${element_node.highlight_index ?? 'unknown'}.\nAvailable options:\n${details}`);
2552
2845
  }
2553
- continue;
2554
2846
  }
2555
- const clicked = await this._withAbort(frame.evaluate(({ xpath, optionText }) => {
2556
- const root = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
2557
- if (!root)
2558
- return false;
2559
- const nodes = root.querySelectorAll('[role="menuitem"],[role="option"]');
2560
- const options = Array.from(nodes).map((node, index) => ({
2561
- index,
2562
- text: node.textContent?.trim() ?? '',
2563
- value: node.textContent?.trim() ?? '',
2564
- }));
2565
- const targetRaw = optionText.trim();
2566
- const targetLower = optionText.trim().toLowerCase();
2567
- let matchedIndex = options.findIndex((opt) => opt.text === targetRaw || opt.value === targetRaw);
2568
- if (matchedIndex < 0) {
2569
- matchedIndex = options.findIndex((opt) => opt.text.trim().toLowerCase() === targetLower ||
2570
- opt.value.trim().toLowerCase() === targetLower);
2571
- }
2572
- if (matchedIndex < 0) {
2573
- return { found: true, success: false, options };
2847
+ catch (error) {
2848
+ if (error instanceof BrowserError) {
2849
+ throw error;
2574
2850
  }
2575
- nodes[matchedIndex].click();
2576
- return {
2577
- found: true,
2578
- success: true,
2579
- options,
2580
- matched: options[matchedIndex],
2581
- };
2582
- }, { xpath: element_node.xpath, optionText: text }), signal);
2583
- if (clicked?.found && clicked.success) {
2584
- const matchedText = clicked.matched?.text ?? text;
2585
- const msg = `Selected menu item ${matchedText}`;
2586
- return {
2587
- message: msg,
2588
- short_term_memory: msg,
2589
- long_term_memory: msg,
2590
- matched_text: String(matchedText),
2591
- };
2592
- }
2593
- if (clicked?.found) {
2594
- const details = formatAvailableOptions(clicked.options ?? []);
2595
- throw new BrowserError(`Could not select option '${text}' for index ${element_node.highlight_index ?? 'unknown'}.\nAvailable options:\n${details}`);
2596
- }
2597
- }
2598
- catch (error) {
2599
- if (error instanceof BrowserError) {
2600
- throw error;
2851
+ continue;
2601
2852
  }
2602
- continue;
2603
2853
  }
2854
+ throw new BrowserError(`Could not select option '${text}' for index ${element_node.highlight_index ?? 'unknown'}`);
2855
+ }
2856
+ finally {
2857
+ await this.validate_page_after_action(page, signal);
2604
2858
  }
2605
- throw new BrowserError(`Could not select option '${text}' for index ${element_node.highlight_index ?? 'unknown'}`);
2606
2859
  }
2607
2860
  async upload_file(element_node, file_path, options = {}) {
2608
2861
  const signal = options.signal ?? null;
@@ -2618,7 +2871,14 @@ export class BrowserSession {
2618
2871
  if (typeof locatorWithUpload.setInputFiles !== 'function') {
2619
2872
  throw new Error('Element does not support file upload');
2620
2873
  }
2621
- await this._withAbort(locatorWithUpload.setInputFiles(file_path, { timeout: 5000 }), signal);
2874
+ const page = await this._withAbort(this.get_current_page(), signal);
2875
+ await this.validate_page_after_action(page, signal);
2876
+ try {
2877
+ await this._withAbort(locatorWithUpload.setInputFiles(file_path, { timeout: 5000 }), signal);
2878
+ }
2879
+ finally {
2880
+ await this.validate_page_after_action(page, signal);
2881
+ }
2622
2882
  }
2623
2883
  async go_back(options = {}) {
2624
2884
  const signal = options.signal ?? null;
@@ -2627,16 +2887,25 @@ export class BrowserSession {
2627
2887
  if (!page?.goBack) {
2628
2888
  return;
2629
2889
  }
2890
+ await this.validate_page_after_action(page, signal);
2630
2891
  const previousUrl = this.currentUrl;
2631
2892
  try {
2632
2893
  await this._withAbort(page.goBack(), signal);
2894
+ await this._waitForStableNetwork(page, signal);
2895
+ await this._assert_page_url_allowed_or_rollback(page);
2633
2896
  }
2634
2897
  catch (error) {
2635
2898
  if (this._isAbortError(error)) {
2636
2899
  throw error;
2637
2900
  }
2901
+ if (error instanceof URLNotAllowedError) {
2902
+ throw error;
2903
+ }
2638
2904
  this.logger.debug(`Failed to navigate back: ${error.message}`);
2639
2905
  }
2906
+ finally {
2907
+ await this.validate_page_after_action(page, signal);
2908
+ }
2640
2909
  this._throwIfAborted(signal);
2641
2910
  await this._syncCurrentTabFromPage(page);
2642
2911
  const currentUrl = this.currentUrl;
@@ -2684,8 +2953,9 @@ export class BrowserSession {
2684
2953
  }
2685
2954
  static async get_unique_filename(directory, filename) {
2686
2955
  const resolvedDir = path.resolve(directory);
2687
- const parsed = path.parse(filename);
2688
- let candidate = filename;
2956
+ const safeFilename = BrowserSession._sanitize_download_filename(filename);
2957
+ const parsed = path.parse(safeFilename);
2958
+ let candidate = safeFilename;
2689
2959
  let counter = 1;
2690
2960
  while (fs.existsSync(path.join(resolvedDir, candidate))) {
2691
2961
  candidate = `${parsed.name} (${counter})${parsed.ext}`;
@@ -2693,6 +2963,81 @@ export class BrowserSession {
2693
2963
  }
2694
2964
  return candidate;
2695
2965
  }
2966
+ async _cancel_download_best_effort(download) {
2967
+ if (typeof download?.cancel !== 'function') {
2968
+ return;
2969
+ }
2970
+ try {
2971
+ await download.cancel();
2972
+ }
2973
+ catch (error) {
2974
+ this.logger.debug(`Failed to cancel blocked download: ${error.message}`);
2975
+ }
2976
+ }
2977
+ async _assert_download_url_allowed(download, downloadUrl) {
2978
+ try {
2979
+ this._assert_url_allowed(downloadUrl);
2980
+ }
2981
+ catch (error) {
2982
+ await this._cancel_download_best_effort(download);
2983
+ throw error;
2984
+ }
2985
+ }
2986
+ static _sanitize_download_filename(filename) {
2987
+ const basename = String(filename || '')
2988
+ .replace(/\0/g, '')
2989
+ .replace(/\\/g, '/')
2990
+ .split('/')
2991
+ .pop()
2992
+ ?.trim();
2993
+ if (!basename || basename === '.' || basename === '..') {
2994
+ return 'download';
2995
+ }
2996
+ const sanitized = basename
2997
+ .replace(/[:*?"<>|]+/g, '_')
2998
+ .replace(/\s+/g, ' ')
2999
+ .trim();
3000
+ return sanitized || 'download';
3001
+ }
3002
+ static _redact_url_for_logging(url) {
3003
+ const raw = String(url ?? '');
3004
+ if (!raw) {
3005
+ return raw;
3006
+ }
3007
+ if (/^data:/i.test(raw)) {
3008
+ return 'data:<redacted>';
3009
+ }
3010
+ try {
3011
+ const parsed = new URL(raw);
3012
+ parsed.username = '';
3013
+ parsed.password = '';
3014
+ const [withoutHash] = parsed.href.split('#', 1);
3015
+ const [withoutQuery] = withoutHash.split('?', 1);
3016
+ return `${withoutQuery}${parsed.search ? '?<redacted>' : ''}${parsed.hash ? '#<redacted>' : ''}`;
3017
+ }
3018
+ catch {
3019
+ const queryIndex = raw.indexOf('?');
3020
+ const hashIndex = raw.indexOf('#');
3021
+ const firstSensitiveIndex = [queryIndex, hashIndex]
3022
+ .filter((index) => index >= 0)
3023
+ .sort((a, b) => a - b)[0];
3024
+ if (firstSensitiveIndex === undefined) {
3025
+ return raw;
3026
+ }
3027
+ return `${raw.slice(0, firstSensitiveIndex)}${queryIndex >= 0 ? '?<redacted>' : ''}${hashIndex >= 0 ? '#<redacted>' : ''}`;
3028
+ }
3029
+ }
3030
+ static _redact_urls_in_text(value) {
3031
+ return value.replace(/\b(?:https?|blob):[^\s"'<>]+/gi, (match) => BrowserSession._redact_url_for_logging(match));
3032
+ }
3033
+ static _same_origin(left, right) {
3034
+ try {
3035
+ return new URL(left).origin === new URL(right).origin;
3036
+ }
3037
+ catch {
3038
+ return false;
3039
+ }
3040
+ }
2696
3041
  async get_selector_map(options = {}) {
2697
3042
  if (!this.cachedBrowserState) {
2698
3043
  await this.get_browser_state_with_recovery({
@@ -2763,6 +3108,7 @@ export class BrowserSession {
2763
3108
  if (!page || !node?.xpath) {
2764
3109
  return null;
2765
3110
  }
3111
+ await this.validate_page_after_action(page);
2766
3112
  try {
2767
3113
  const locator = page.locator(`xpath=${node.xpath}`);
2768
3114
  const count = await locator.count();
@@ -2784,12 +3130,19 @@ export class BrowserSession {
2784
3130
  if (!locator) {
2785
3131
  throw new Error('Element not found');
2786
3132
  }
2787
- await this._withAbort(locator.click({ timeout: 5000 }), signal);
2788
- if (clear) {
2789
- await this._withAbort(locator.fill(text, { timeout: 5000 }), signal);
3133
+ const page = await this._withAbort(this.get_current_page(), signal);
3134
+ await this.validate_page_after_action(page, signal);
3135
+ try {
3136
+ await this._withAbort(locator.click({ timeout: 5000 }), signal);
3137
+ if (clear) {
3138
+ await this._withAbort(locator.fill(text, { timeout: 5000 }), signal);
3139
+ }
3140
+ else {
3141
+ await this._withAbort(locator.type(text, { timeout: 5000 }), signal);
3142
+ }
2790
3143
  }
2791
- else {
2792
- await this._withAbort(locator.type(text, { timeout: 5000 }), signal);
3144
+ finally {
3145
+ await this.validate_page_after_action(page, signal);
2793
3146
  }
2794
3147
  }
2795
3148
  async _click_element_node(node, options = {}) {
@@ -2800,76 +3153,87 @@ export class BrowserSession {
2800
3153
  throw new Error('Element not found');
2801
3154
  }
2802
3155
  const page = await this._withAbort(this.get_current_page(), signal);
3156
+ await this.validate_page_after_action(page, signal);
2803
3157
  const performClick = async () => {
2804
3158
  await this._withAbort(locator.click({ timeout: 5000 }), signal);
2805
3159
  };
2806
- const downloadsDir = this.browser_profile.downloads_path;
2807
- if (downloadsDir && page?.waitForEvent) {
2808
- const downloadPromise = page.waitForEvent('download', { timeout: 5000 });
2809
- await performClick();
2810
- try {
2811
- const download = await this._withAbort(downloadPromise, signal);
2812
- const downloadGuid = uuid7str();
2813
- const suggested = typeof download.suggestedFilename === 'function'
2814
- ? download.suggestedFilename()
2815
- : 'download';
2816
- const downloadUrl = typeof download.url === 'function'
2817
- ? download.url()
2818
- : (this.currentUrl ?? '');
2819
- await this.event_bus.dispatch(new DownloadStartedEvent({
2820
- guid: downloadGuid,
2821
- url: downloadUrl,
2822
- suggested_filename: suggested,
2823
- auto_download: false,
2824
- }));
2825
- const uniqueFilename = await BrowserSession.get_unique_filename(downloadsDir, suggested);
2826
- const downloadPath = path.join(downloadsDir, uniqueFilename);
2827
- if (typeof download.saveAs === 'function') {
2828
- await download.saveAs(downloadPath);
2829
- }
2830
- const stats = fs.existsSync(downloadPath)
2831
- ? fs.statSync(downloadPath)
2832
- : null;
2833
- await this.event_bus.dispatch(new DownloadProgressEvent({
2834
- guid: downloadGuid,
2835
- received_bytes: stats?.size ?? 0,
2836
- total_bytes: stats?.size ?? 0,
2837
- state: 'completed',
2838
- }));
2839
- const fileDownloadedResult = await this.event_bus.dispatch(new FileDownloadedEvent({
2840
- guid: downloadGuid,
2841
- url: downloadUrl,
2842
- path: downloadPath,
2843
- file_name: uniqueFilename,
2844
- file_size: stats?.size ?? 0,
2845
- file_type: path.extname(uniqueFilename).replace('.', '') || null,
2846
- mime_type: null,
2847
- auto_download: false,
2848
- }));
2849
- if (fileDownloadedResult.handler_results.length === 0) {
2850
- this.add_downloaded_file(downloadPath);
3160
+ let result = null;
3161
+ try {
3162
+ const downloadsDir = this.browser_profile.downloads_path;
3163
+ if (downloadsDir && page?.waitForEvent) {
3164
+ ensurePrivateDirectoryIfCreated(downloadsDir);
3165
+ const downloadPromise = page.waitForEvent('download', {
3166
+ timeout: 5000,
3167
+ });
3168
+ await performClick();
3169
+ try {
3170
+ const download = await this._withAbort(downloadPromise, signal);
3171
+ const downloadGuid = uuid7str();
3172
+ const suggested = typeof download.suggestedFilename === 'function'
3173
+ ? download.suggestedFilename()
3174
+ : 'download';
3175
+ const downloadUrl = typeof download.url === 'function'
3176
+ ? download.url()
3177
+ : (this.currentUrl ?? '');
3178
+ await this._assert_download_url_allowed(download, downloadUrl);
3179
+ await this.event_bus.dispatch(new DownloadStartedEvent({
3180
+ guid: downloadGuid,
3181
+ url: downloadUrl,
3182
+ suggested_filename: suggested,
3183
+ auto_download: false,
3184
+ }));
3185
+ const uniqueFilename = await BrowserSession.get_unique_filename(downloadsDir, suggested);
3186
+ const downloadPath = path.join(downloadsDir, uniqueFilename);
3187
+ if (typeof download.saveAs === 'function') {
3188
+ await download.saveAs(downloadPath);
3189
+ chmodPrivateFileBestEffort(downloadPath);
3190
+ }
3191
+ const stats = fs.existsSync(downloadPath)
3192
+ ? fs.statSync(downloadPath)
3193
+ : null;
3194
+ await this.event_bus.dispatch(new DownloadProgressEvent({
3195
+ guid: downloadGuid,
3196
+ received_bytes: stats?.size ?? 0,
3197
+ total_bytes: stats?.size ?? 0,
3198
+ state: 'completed',
3199
+ }));
3200
+ const fileDownloadedResult = await this.event_bus.dispatch(new FileDownloadedEvent({
3201
+ guid: downloadGuid,
3202
+ url: downloadUrl,
3203
+ path: downloadPath,
3204
+ file_name: uniqueFilename,
3205
+ file_size: stats?.size ?? 0,
3206
+ file_type: path.extname(uniqueFilename).replace('.', '') || null,
3207
+ mime_type: null,
3208
+ auto_download: false,
3209
+ }));
3210
+ if (fileDownloadedResult.handler_results.length === 0) {
3211
+ this.add_downloaded_file(downloadPath);
3212
+ }
3213
+ result = downloadPath;
2851
3214
  }
2852
- return downloadPath;
2853
- }
2854
- catch (error) {
2855
- if (this._isAbortError(error)) {
2856
- throw error;
3215
+ catch (error) {
3216
+ if (this._isAbortError(error)) {
3217
+ throw error;
3218
+ }
3219
+ if (error instanceof URLNotAllowedError) {
3220
+ throw error;
3221
+ }
3222
+ this.logger.debug(`No download triggered within timeout: ${error.message}`);
2857
3223
  }
2858
- this.logger.debug(`No download triggered within timeout: ${error.message}`);
3224
+ }
3225
+ else {
3226
+ await performClick();
2859
3227
  }
2860
3228
  }
2861
- else {
2862
- await performClick();
2863
- }
2864
- await this._waitForLoad(page, 5000, signal);
2865
- if (page) {
2866
- await this._syncCurrentTabFromPage(page);
2867
- if (this.historyStack[this.historyStack.length - 1] !== this.currentUrl) {
3229
+ finally {
3230
+ await this.validate_page_after_action(page, signal);
3231
+ if (page &&
3232
+ this.historyStack[this.historyStack.length - 1] !== this.currentUrl) {
2868
3233
  this.historyStack.push(this.currentUrl);
2869
3234
  }
2870
3235
  }
2871
- this.cachedBrowserState = null;
2872
- return null;
3236
+ return result;
2873
3237
  }
2874
3238
  async _waitForLoad(page, timeout = 5000, signal = null) {
2875
3239
  if (!page || typeof page.waitForLoadState !== 'function') {
@@ -2889,11 +3253,14 @@ export class BrowserSession {
2889
3253
  /**
2890
3254
  * Get all cookies from the current browser context
2891
3255
  */
2892
- async get_cookies() {
2893
- if (this.browser_context?.cookies) {
2894
- return await this.browser_context.cookies();
3256
+ async get_cookies(options = {}) {
3257
+ const cookies = this.browser_context?.cookies
3258
+ ? await this.browser_context.cookies()
3259
+ : [];
3260
+ if (options.include_blocked || !this._has_url_access_restrictions()) {
3261
+ return cookies;
2895
3262
  }
2896
- return [];
3263
+ return this._filter_cookies_for_exposure(cookies);
2897
3264
  }
2898
3265
  /**
2899
3266
  * Save cookies to a file (deprecated, use save_storage_state instead)
@@ -2925,19 +3292,19 @@ export class BrowserSession {
2925
3292
  const resolvedPath = path.resolve(targetPath);
2926
3293
  const dirPath = path.dirname(resolvedPath);
2927
3294
  // Create directory if it doesn't exist
2928
- if (!fs.existsSync(dirPath)) {
2929
- fs.mkdirSync(dirPath, { recursive: true });
2930
- }
3295
+ ensurePrivateDirectoryIfCreated(dirPath);
2931
3296
  // Get storage state from browser context
2932
- const storageState = await this.browser_context.storageState();
3297
+ const rawStorageState = await this.browser_context.storageState();
3298
+ const storageState = this._sanitize_storage_state_for_save(rawStorageState);
2933
3299
  // Write to temporary file first
2934
3300
  const tempPath = `${resolvedPath}.tmp`;
2935
- fs.writeFileSync(tempPath, JSON.stringify(storageState, null, 2));
3301
+ writePrivateStorageFile(tempPath, JSON.stringify(storageState, null, 2));
2936
3302
  // Backup existing file if present
2937
3303
  if (fs.existsSync(resolvedPath)) {
2938
3304
  const backupPath = `${resolvedPath}.bak`;
2939
3305
  try {
2940
3306
  fs.renameSync(resolvedPath, backupPath);
3307
+ chmodPrivateStorageFile(backupPath);
2941
3308
  }
2942
3309
  catch (error) {
2943
3310
  // Ignore backup errors
@@ -2945,6 +3312,7 @@ export class BrowserSession {
2945
3312
  }
2946
3313
  // Move temp file to target
2947
3314
  fs.renameSync(tempPath, resolvedPath);
3315
+ chmodPrivateStorageFile(resolvedPath);
2948
3316
  const cookieCount = storageState.cookies?.length || 0;
2949
3317
  this.logger.info(`🍪 Saved ${cookieCount} cookies to ${path.basename(resolvedPath)}`);
2950
3318
  }
@@ -2971,15 +3339,216 @@ export class BrowserSession {
2971
3339
  if (this.browser_context?.addCookies) {
2972
3340
  // Add cookies to context
2973
3341
  if (storageState.cookies && Array.isArray(storageState.cookies)) {
2974
- await this.browser_context.addCookies(storageState.cookies);
2975
- this.logger.info(`🍪 Loaded ${storageState.cookies.length} cookies from ${path.basename(resolvedPath)}`);
3342
+ const allowedCookies = this._filter_storage_state_cookies(storageState.cookies);
3343
+ if (allowedCookies.length > 0) {
3344
+ await this.browser_context.addCookies(allowedCookies);
3345
+ }
3346
+ this.logger.info(`🍪 Loaded ${allowedCookies.length} cookies from ${path.basename(resolvedPath)}`);
2976
3347
  }
2977
3348
  }
3349
+ if (Array.isArray(storageState.origins)) {
3350
+ await this._apply_storage_state_origins(storageState.origins);
3351
+ }
2978
3352
  }
2979
3353
  catch (error) {
2980
3354
  this.logger.warning(`❌ Failed to load storage state: ${error.message}`);
2981
3355
  }
2982
3356
  }
3357
+ async _apply_storage_state_origins(origins) {
3358
+ const browserContext = this.browser_context;
3359
+ if (!browserContext?.newPage) {
3360
+ return;
3361
+ }
3362
+ for (const originState of origins) {
3363
+ const origin = originState &&
3364
+ typeof originState === 'object' &&
3365
+ 'origin' in originState &&
3366
+ typeof originState.origin === 'string'
3367
+ ? originState.origin.trim()
3368
+ : '';
3369
+ if (!origin || !/^https?:\/\//i.test(origin)) {
3370
+ continue;
3371
+ }
3372
+ const denialReason = this._get_url_access_denial_reason(origin);
3373
+ if (denialReason) {
3374
+ this.logger.warning(`Skipping storage origin ${BrowserSession._redact_url_for_logging(origin)}: ${denialReason}`);
3375
+ continue;
3376
+ }
3377
+ const localStorageEntries = this._normalize_storage_entries(originState.localStorage);
3378
+ const sessionStorageEntries = this._normalize_storage_entries(originState.sessionStorage);
3379
+ if (localStorageEntries.length === 0 &&
3380
+ sessionStorageEntries.length === 0) {
3381
+ continue;
3382
+ }
3383
+ let page = null;
3384
+ try {
3385
+ page = await browserContext.newPage();
3386
+ await page.goto?.(origin, {
3387
+ waitUntil: 'domcontentloaded',
3388
+ timeout: 5_000,
3389
+ });
3390
+ const finalUrl = typeof page.url === 'function' ? page.url() : origin;
3391
+ const finalDenialReason = this._get_url_access_denial_reason(finalUrl);
3392
+ if (finalDenialReason) {
3393
+ this.logger.warning(`Skipping storage origin ${BrowserSession._redact_url_for_logging(origin)} after redirect to blocked URL: ${finalDenialReason}`);
3394
+ try {
3395
+ await page.goto?.('about:blank', {
3396
+ waitUntil: 'load',
3397
+ timeout: 5_000,
3398
+ });
3399
+ }
3400
+ catch {
3401
+ // The temporary page is closed below; resetting first is best effort.
3402
+ }
3403
+ continue;
3404
+ }
3405
+ if (!BrowserSession._same_origin(origin, finalUrl)) {
3406
+ this.logger.warning(`Skipping storage origin ${BrowserSession._redact_url_for_logging(origin)} after redirect to a different origin`);
3407
+ try {
3408
+ await page.goto?.('about:blank', {
3409
+ waitUntil: 'load',
3410
+ timeout: 5_000,
3411
+ });
3412
+ }
3413
+ catch {
3414
+ // The temporary page is closed below; resetting first is best effort.
3415
+ }
3416
+ continue;
3417
+ }
3418
+ await page.evaluate?.((payload) => {
3419
+ for (const entry of payload.localStorageEntries) {
3420
+ window.localStorage.setItem(entry.name, entry.value);
3421
+ }
3422
+ for (const entry of payload.sessionStorageEntries) {
3423
+ window.sessionStorage.setItem(entry.name, entry.value);
3424
+ }
3425
+ }, {
3426
+ localStorageEntries,
3427
+ sessionStorageEntries,
3428
+ });
3429
+ }
3430
+ catch (error) {
3431
+ this.logger.debug(`Failed to apply origin storage for ${BrowserSession._redact_url_for_logging(origin)}: ${error.message}`);
3432
+ }
3433
+ finally {
3434
+ try {
3435
+ await page?.close?.();
3436
+ }
3437
+ catch {
3438
+ // Ignore cleanup errors.
3439
+ }
3440
+ }
3441
+ }
3442
+ }
3443
+ _normalize_storage_entries(entries) {
3444
+ if (!Array.isArray(entries)) {
3445
+ return [];
3446
+ }
3447
+ const normalized = [];
3448
+ for (const entry of entries) {
3449
+ const name = entry && typeof entry === 'object' && 'name' in entry
3450
+ ? String(entry.name ?? '')
3451
+ : '';
3452
+ if (!name) {
3453
+ continue;
3454
+ }
3455
+ const value = entry && typeof entry === 'object' && 'value' in entry
3456
+ ? String(entry.value ?? '')
3457
+ : '';
3458
+ normalized.push({ name, value });
3459
+ }
3460
+ return normalized;
3461
+ }
3462
+ _sanitize_storage_state_for_save(storageState) {
3463
+ if (!this._has_url_access_restrictions()) {
3464
+ return storageState;
3465
+ }
3466
+ const normalized = storageState && typeof storageState === 'object'
3467
+ ? { ...storageState }
3468
+ : {};
3469
+ const cookies = Array.isArray(normalized.cookies)
3470
+ ? this._filter_storage_state_cookies(normalized.cookies)
3471
+ : [];
3472
+ const origins = Array.isArray(normalized.origins)
3473
+ ? normalized.origins.filter((originState) => {
3474
+ const origin = originState &&
3475
+ typeof originState === 'object' &&
3476
+ 'origin' in originState &&
3477
+ typeof originState.origin === 'string'
3478
+ ? originState.origin.trim()
3479
+ : '';
3480
+ const denialReason = origin
3481
+ ? this._get_url_access_denial_reason(origin)
3482
+ : 'invalid_url';
3483
+ if (!denialReason) {
3484
+ return true;
3485
+ }
3486
+ this.logger.warning(`Skipping saved storage origin ${origin
3487
+ ? BrowserSession._redact_url_for_logging(origin)
3488
+ : '<invalid>'}: ${denialReason}`);
3489
+ return false;
3490
+ })
3491
+ : [];
3492
+ return {
3493
+ ...normalized,
3494
+ cookies,
3495
+ origins,
3496
+ };
3497
+ }
3498
+ _filter_storage_state_cookies(cookies) {
3499
+ return cookies.filter((cookie) => {
3500
+ const denialReason = this._get_cookie_access_denial_reason(cookie);
3501
+ if (!denialReason) {
3502
+ return true;
3503
+ }
3504
+ const cookieName = cookie && typeof cookie === 'object' && 'name' in cookie
3505
+ ? String(cookie.name ?? '')
3506
+ : '';
3507
+ this.logger.warning(`Skipping storage cookie ${cookieName || '<unnamed>'}: ${denialReason}`);
3508
+ return false;
3509
+ });
3510
+ }
3511
+ _filter_cookies_for_exposure(cookies) {
3512
+ return cookies.filter((cookie) => {
3513
+ const denialReason = this._get_cookie_access_denial_reason(cookie);
3514
+ if (!denialReason) {
3515
+ return true;
3516
+ }
3517
+ const cookieName = cookie && typeof cookie === 'object' && 'name' in cookie
3518
+ ? String(cookie.name ?? '')
3519
+ : '';
3520
+ this.logger.debug(`Skipping exposed cookie ${cookieName || '<unnamed>'}: ${denialReason}`);
3521
+ return false;
3522
+ });
3523
+ }
3524
+ _get_cookie_access_denial_reason(cookie) {
3525
+ if (!cookie || typeof cookie !== 'object') {
3526
+ return null;
3527
+ }
3528
+ const cookieLike = cookie;
3529
+ const explicitUrl = typeof cookieLike.url === 'string' ? cookieLike.url.trim() : '';
3530
+ if (explicitUrl) {
3531
+ return this._get_url_access_denial_reason(explicitUrl);
3532
+ }
3533
+ const rawDomain = typeof cookieLike.domain === 'string' ? cookieLike.domain.trim() : '';
3534
+ const host = rawDomain.replace(/^\./, '');
3535
+ if (!host) {
3536
+ return null;
3537
+ }
3538
+ const protocols = cookieLike.secure === true ? ['https:'] : ['https:', 'http:'];
3539
+ let lastReason = null;
3540
+ for (const protocol of protocols) {
3541
+ const reason = this._get_url_access_denial_reason(`${protocol}//${host}`);
3542
+ if (!reason) {
3543
+ return null;
3544
+ }
3545
+ if (reason === 'in_prohibited_domains') {
3546
+ return reason;
3547
+ }
3548
+ lastReason = reason;
3549
+ }
3550
+ return lastReason;
3551
+ }
2983
3552
  // ==================== JavaScript Execution ====================
2984
3553
  /**
2985
3554
  * Execute JavaScript in the current page context
@@ -2989,7 +3558,24 @@ export class BrowserSession {
2989
3558
  if (!page) {
2990
3559
  throw new Error('No page available to execute JavaScript');
2991
3560
  }
2992
- return await page.evaluate(script);
3561
+ await this.validate_page_after_action(page);
3562
+ try {
3563
+ return await page.evaluate(script);
3564
+ }
3565
+ finally {
3566
+ await this._waitForLoad(page, 5000);
3567
+ await this._assert_page_url_allowed_or_rollback(page);
3568
+ await this._syncCurrentTabFromPage(page);
3569
+ this.cachedBrowserState = null;
3570
+ }
3571
+ }
3572
+ async validate_page_after_action(page, signal = null) {
3573
+ await this._waitForLoad(page, 5000, signal);
3574
+ if (page) {
3575
+ await this._assert_page_url_allowed_or_rollback(page);
3576
+ await this._syncCurrentTabFromPage(page);
3577
+ }
3578
+ this.cachedBrowserState = null;
2993
3579
  }
2994
3580
  // ==================== Page Information ====================
2995
3581
  /**
@@ -3000,49 +3586,55 @@ export class BrowserSession {
3000
3586
  if (!targetPage) {
3001
3587
  return null;
3002
3588
  }
3003
- const pageData = await targetPage.evaluate(() => {
3589
+ await this.validate_page_after_action(targetPage);
3590
+ try {
3591
+ const pageData = await targetPage.evaluate(() => {
3592
+ return {
3593
+ // Current viewport dimensions
3594
+ viewport_width: window.innerWidth,
3595
+ viewport_height: window.innerHeight,
3596
+ // Total page dimensions
3597
+ page_width: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth || 0),
3598
+ page_height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight || 0),
3599
+ // Current scroll position
3600
+ scroll_x: window.scrollX ||
3601
+ window.pageXOffset ||
3602
+ document.documentElement.scrollLeft ||
3603
+ 0,
3604
+ scroll_y: window.scrollY ||
3605
+ window.pageYOffset ||
3606
+ document.documentElement.scrollTop ||
3607
+ 0,
3608
+ };
3609
+ });
3610
+ // Calculate derived values
3611
+ const viewport_width = Math.floor(pageData.viewport_width);
3612
+ const viewport_height = Math.floor(pageData.viewport_height);
3613
+ const page_width = Math.floor(pageData.page_width);
3614
+ const page_height = Math.floor(pageData.page_height);
3615
+ const scroll_x = Math.floor(pageData.scroll_x);
3616
+ const scroll_y = Math.floor(pageData.scroll_y);
3617
+ // Calculate scroll information
3618
+ const pixels_above = scroll_y;
3619
+ const pixels_below = Math.max(0, page_height - (scroll_y + viewport_height));
3620
+ const pixels_left = scroll_x;
3621
+ const pixels_right = Math.max(0, page_width - (scroll_x + viewport_width));
3004
3622
  return {
3005
- // Current viewport dimensions
3006
- viewport_width: window.innerWidth,
3007
- viewport_height: window.innerHeight,
3008
- // Total page dimensions
3009
- page_width: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth || 0),
3010
- page_height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight || 0),
3011
- // Current scroll position
3012
- scroll_x: window.scrollX ||
3013
- window.pageXOffset ||
3014
- document.documentElement.scrollLeft ||
3015
- 0,
3016
- scroll_y: window.scrollY ||
3017
- window.pageYOffset ||
3018
- document.documentElement.scrollTop ||
3019
- 0,
3623
+ viewport_width,
3624
+ viewport_height,
3625
+ page_width,
3626
+ page_height,
3627
+ scroll_x,
3628
+ scroll_y,
3629
+ pixels_above,
3630
+ pixels_below,
3631
+ pixels_left,
3632
+ pixels_right,
3020
3633
  };
3021
- });
3022
- // Calculate derived values
3023
- const viewport_width = Math.floor(pageData.viewport_width);
3024
- const viewport_height = Math.floor(pageData.viewport_height);
3025
- const page_width = Math.floor(pageData.page_width);
3026
- const page_height = Math.floor(pageData.page_height);
3027
- const scroll_x = Math.floor(pageData.scroll_x);
3028
- const scroll_y = Math.floor(pageData.scroll_y);
3029
- // Calculate scroll information
3030
- const pixels_above = scroll_y;
3031
- const pixels_below = Math.max(0, page_height - (scroll_y + viewport_height));
3032
- const pixels_left = scroll_x;
3033
- const pixels_right = Math.max(0, page_width - (scroll_x + viewport_width));
3034
- return {
3035
- viewport_width,
3036
- viewport_height,
3037
- page_width,
3038
- page_height,
3039
- scroll_x,
3040
- scroll_y,
3041
- pixels_above,
3042
- pixels_below,
3043
- pixels_left,
3044
- pixels_right,
3045
- };
3634
+ }
3635
+ finally {
3636
+ await this.validate_page_after_action(targetPage);
3637
+ }
3046
3638
  }
3047
3639
  /**
3048
3640
  * Get the HTML content of the current page
@@ -3052,7 +3644,13 @@ export class BrowserSession {
3052
3644
  if (!page) {
3053
3645
  return '';
3054
3646
  }
3055
- return await page.content();
3647
+ await this.validate_page_after_action(page);
3648
+ try {
3649
+ return await page.content();
3650
+ }
3651
+ finally {
3652
+ await this.validate_page_after_action(page);
3653
+ }
3056
3654
  }
3057
3655
  /**
3058
3656
  * Get a debug view of the page structure including iframes
@@ -3062,6 +3660,7 @@ export class BrowserSession {
3062
3660
  if (!page) {
3063
3661
  return '';
3064
3662
  }
3663
+ await this.validate_page_after_action(page);
3065
3664
  const debug_script = `(() => {
3066
3665
  function getPageStructure(element = document, depth = 0, maxDepth = 10) {
3067
3666
  if (depth >= maxDepth) return '';
@@ -3125,20 +3724,31 @@ export class BrowserSession {
3125
3724
 
3126
3725
  return getPageStructure();
3127
3726
  })()`;
3128
- return await page.evaluate(debug_script);
3727
+ try {
3728
+ return await page.evaluate(debug_script);
3729
+ }
3730
+ finally {
3731
+ await this.validate_page_after_action(page);
3732
+ }
3129
3733
  }
3130
3734
  // ==================== Navigation & History ====================
3131
3735
  /**
3132
3736
  * Navigate forward in browser history
3133
3737
  */
3134
3738
  async go_forward() {
3739
+ const page = await this.get_current_page();
3740
+ if (!page?.goForward) {
3741
+ return;
3742
+ }
3743
+ await this.validate_page_after_action(page);
3135
3744
  try {
3136
- const page = await this.get_current_page();
3137
- if (page?.goForward) {
3138
- await page.goForward({ timeout: 10000, waitUntil: 'load' });
3139
- }
3745
+ await page.goForward({ timeout: 10000, waitUntil: 'load' });
3746
+ await this._waitForStableNetwork(page);
3140
3747
  }
3141
3748
  catch (error) {
3749
+ if (error instanceof URLNotAllowedError) {
3750
+ throw error;
3751
+ }
3142
3752
  this.logger.debug(`⏭️ Error during go_forward: ${error.message}`);
3143
3753
  // Verify page is still usable after navigation error
3144
3754
  if (error.message.toLowerCase().includes('timeout')) {
@@ -3151,22 +3761,33 @@ export class BrowserSession {
3151
3761
  }
3152
3762
  }
3153
3763
  }
3764
+ finally {
3765
+ await this.validate_page_after_action(page);
3766
+ }
3154
3767
  }
3155
3768
  /**
3156
3769
  * Refresh the current page
3157
3770
  */
3158
3771
  async refresh() {
3772
+ const page = await this.get_current_page();
3773
+ if (!page?.reload) {
3774
+ return;
3775
+ }
3776
+ await this.validate_page_after_action(page);
3159
3777
  try {
3160
- const page = await this.get_current_page();
3161
- if (page?.reload) {
3162
- this.currentPageLoadingStatus = null;
3163
- await page.reload({ waitUntil: 'domcontentloaded' });
3164
- await this._waitForStableNetwork(page);
3165
- }
3778
+ this.currentPageLoadingStatus = null;
3779
+ await page.reload({ waitUntil: 'domcontentloaded' });
3780
+ await this._waitForStableNetwork(page);
3166
3781
  }
3167
3782
  catch (error) {
3783
+ if (error instanceof URLNotAllowedError) {
3784
+ throw error;
3785
+ }
3168
3786
  this.logger.debug(`🔄 Error during refresh: ${error.message}`);
3169
3787
  }
3788
+ finally {
3789
+ await this.validate_page_after_action(page);
3790
+ }
3170
3791
  }
3171
3792
  // ==================== Element Waiting ====================
3172
3793
  /**
@@ -3177,7 +3798,13 @@ export class BrowserSession {
3177
3798
  if (!page) {
3178
3799
  throw new Error('No page available');
3179
3800
  }
3180
- await page.waitForSelector(selector, { state: 'visible', timeout });
3801
+ await this.validate_page_after_action(page);
3802
+ try {
3803
+ await page.waitForSelector(selector, { state: 'visible', timeout });
3804
+ }
3805
+ finally {
3806
+ await this.validate_page_after_action(page);
3807
+ }
3181
3808
  }
3182
3809
  // ==================== Screenshots ====================
3183
3810
  /**
@@ -3191,15 +3818,17 @@ export class BrowserSession {
3191
3818
  if (!page) {
3192
3819
  throw new Error('No page available for screenshot');
3193
3820
  }
3821
+ await this.validate_page_after_action(page);
3194
3822
  if (!this.browser_context) {
3195
3823
  throw new Error('Browser context is not set');
3196
3824
  }
3197
3825
  // Check if it's a new tab page
3198
3826
  const url = page.url();
3827
+ const logUrl = BrowserSession._redact_url_for_logging(url);
3199
3828
  if (url === 'about:blank' ||
3200
3829
  url === 'chrome://newtab/' ||
3201
3830
  url === 'edge://newtab/') {
3202
- this.logger.warning(`▫️ Skipping screenshot of empty page: ${url}`);
3831
+ this.logger.warning(`▫️ Skipping screenshot of empty page: ${logUrl}`);
3203
3832
  // Return a 4px placeholder
3204
3833
  return 'iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAD0lEQVQIHWP8//8/AxYMACgtBP9g8jqYAAAAAElFTkSuQmCC';
3205
3834
  }
@@ -3213,7 +3842,7 @@ export class BrowserSession {
3213
3842
  // Take screenshot using CDP for better performance
3214
3843
  let cdp_session = null;
3215
3844
  try {
3216
- this.logger.debug(`📸 Taking ${full_page ? 'full-page' : 'viewport'} PNG screenshot via CDP: ${url}`);
3845
+ this.logger.debug(`📸 Taking ${full_page ? 'full-page' : 'viewport'} PNG screenshot via CDP: ${logUrl}`);
3217
3846
  // Create CDP session for the screenshot
3218
3847
  cdp_session = await this.get_or_create_cdp_session(page);
3219
3848
  // Capture screenshot via CDP
@@ -3234,17 +3863,17 @@ export class BrowserSession {
3234
3863
  const screenshot_response = await cdp_session.send('Page.captureScreenshot', screenshotParams);
3235
3864
  const screenshot_b64 = screenshot_response.data;
3236
3865
  if (!screenshot_b64) {
3237
- throw new Error(`CDP returned empty screenshot data for page ${url}`);
3866
+ throw new Error(`CDP returned empty screenshot data for page ${logUrl}`);
3238
3867
  }
3239
3868
  return screenshot_b64;
3240
3869
  }
3241
3870
  catch (error) {
3242
3871
  const error_str = error.message || String(error);
3243
3872
  if (error_str.toLowerCase().includes('timeout')) {
3244
- this.logger.warning(`⏱️ Screenshot timed out on page ${url}: ${error_str}`);
3873
+ this.logger.warning(`⏱️ Screenshot timed out on page ${logUrl}: ${error_str}`);
3245
3874
  }
3246
3875
  else {
3247
- this.logger.error(`❌ Screenshot failed on page ${url}: ${error_str}`);
3876
+ this.logger.error(`❌ Screenshot failed on page ${logUrl}: ${error_str}`);
3248
3877
  }
3249
3878
  throw error;
3250
3879
  }
@@ -3257,6 +3886,7 @@ export class BrowserSession {
3257
3886
  // Ignore detach errors
3258
3887
  }
3259
3888
  }
3889
+ await this.validate_page_after_action(page);
3260
3890
  }
3261
3891
  }
3262
3892
  // ==================== Event Listeners ====================
@@ -3324,6 +3954,16 @@ export class BrowserSession {
3324
3954
  // Keep tab url fallback when page url is unavailable.
3325
3955
  }
3326
3956
  }
3957
+ const exposedTab = this._sanitize_tab_for_exposure({
3958
+ page_id,
3959
+ tab_id,
3960
+ url: currentUrl,
3961
+ title: tab.title || currentUrl,
3962
+ });
3963
+ if (exposedTab.url !== currentUrl) {
3964
+ tabs_info.push(exposedTab);
3965
+ continue;
3966
+ }
3327
3967
  // Skip chrome:// pages and new tab pages
3328
3968
  const isNewTab = currentUrl === 'about:blank' ||
3329
3969
  currentUrl.startsWith('chrome://newtab');
@@ -3359,7 +3999,7 @@ export class BrowserSession {
3359
3999
  tabs_info.push({ page_id, tab_id, url: currentUrl, title });
3360
4000
  }
3361
4001
  catch (error) {
3362
- this.logger.debug(`⚠️ Failed to get tab info for tab #${page_id}: ${currentUrl} (using fallback title)`);
4002
+ this.logger.debug(`⚠️ Failed to get tab info for tab #${page_id}: ${BrowserSession._redact_url_for_logging(currentUrl)} (using fallback title)`);
3363
4003
  if (isNewTab) {
3364
4004
  tabs_info.push({
3365
4005
  page_id,
@@ -3415,22 +4055,28 @@ export class BrowserSession {
3415
4055
  viewport_height: 0,
3416
4056
  };
3417
4057
  }
3418
- return await page.evaluate(() => {
3419
- return {
3420
- scroll_x: window.scrollX ||
3421
- window.pageXOffset ||
3422
- document.documentElement.scrollLeft ||
3423
- 0,
3424
- scroll_y: window.scrollY ||
3425
- window.pageYOffset ||
3426
- document.documentElement.scrollTop ||
3427
- 0,
3428
- page_width: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth || 0),
3429
- page_height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight || 0),
3430
- viewport_width: window.innerWidth,
3431
- viewport_height: window.innerHeight,
3432
- };
3433
- });
4058
+ await this.validate_page_after_action(page);
4059
+ try {
4060
+ return await page.evaluate(() => {
4061
+ return {
4062
+ scroll_x: window.scrollX ||
4063
+ window.pageXOffset ||
4064
+ document.documentElement.scrollLeft ||
4065
+ 0,
4066
+ scroll_y: window.scrollY ||
4067
+ window.pageYOffset ||
4068
+ document.documentElement.scrollTop ||
4069
+ 0,
4070
+ page_width: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth || 0),
4071
+ page_height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight || 0),
4072
+ viewport_width: window.innerWidth,
4073
+ viewport_height: window.innerHeight,
4074
+ };
4075
+ });
4076
+ }
4077
+ finally {
4078
+ await this.validate_page_after_action(page);
4079
+ }
3434
4080
  }
3435
4081
  /**
3436
4082
  * Get a summary of the current browser state
@@ -3469,6 +4115,10 @@ export class BrowserSession {
3469
4115
  async get_minimal_state_summary(include_recent_events = false) {
3470
4116
  try {
3471
4117
  const page = await this.get_current_page();
4118
+ if (page) {
4119
+ await this._assert_page_url_allowed_or_rollback(page);
4120
+ await this._syncCurrentTabFromPage(page);
4121
+ }
3472
4122
  const url = page ? page.url() : 'unknown';
3473
4123
  // Try to get title safely
3474
4124
  let title = 'Page Load Error';
@@ -3554,11 +4204,13 @@ export class BrowserSession {
3554
4204
  if (!page) {
3555
4205
  throw new Error('No current page available');
3556
4206
  }
4207
+ await this.validate_page_after_action(page);
3557
4208
  const page_url = page.url();
4209
+ const logPageUrl = BrowserSession._redact_url_for_logging(page_url);
3558
4210
  // Check for new tab or chrome:// pages - fast path
3559
4211
  const is_empty_page = this._is_new_tab_page(page_url) || page_url.startsWith('chrome://');
3560
4212
  if (is_empty_page) {
3561
- this.logger.debug(`⚡ Fast path for empty page: ${page_url}`);
4213
+ this.logger.debug(`⚡ Fast path for empty page: ${logPageUrl}`);
3562
4214
  // Create minimal DOM state
3563
4215
  const minimal_element_tree = new DOMElementNode(false, null, 'body', '', {}, []);
3564
4216
  const tabs_info = await this.get_tabs_info();
@@ -3604,8 +4256,14 @@ export class BrowserSession {
3604
4256
  await this.remove_highlights();
3605
4257
  }
3606
4258
  catch (error) {
4259
+ if (error instanceof URLNotAllowedError) {
4260
+ throw error;
4261
+ }
3607
4262
  this.logger.debug('Timeout removing highlights');
3608
4263
  }
4264
+ finally {
4265
+ await this.validate_page_after_action(page);
4266
+ }
3609
4267
  // Check for PDF and auto-download if needed
3610
4268
  try {
3611
4269
  const pdf_path = await this._auto_download_pdf_if_needed(page);
@@ -3614,8 +4272,14 @@ export class BrowserSession {
3614
4272
  }
3615
4273
  }
3616
4274
  catch (error) {
4275
+ if (error instanceof URLNotAllowedError) {
4276
+ throw error;
4277
+ }
3617
4278
  this.logger.debug(`PDF auto-download check failed: ${error.message}`);
3618
4279
  }
4280
+ finally {
4281
+ await this.validate_page_after_action(page);
4282
+ }
3619
4283
  // DOM processing
3620
4284
  this.logger.debug('🌳 Starting DOM processing...');
3621
4285
  const dom_service = new DomService(page, this.logger);
@@ -3627,12 +4291,18 @@ export class BrowserSession {
3627
4291
  this.logger.debug('✅ DOM processing completed');
3628
4292
  }
3629
4293
  catch (error) {
3630
- this.logger.warning(`DOM processing timed out for ${page_url}`);
4294
+ if (error instanceof URLNotAllowedError) {
4295
+ throw error;
4296
+ }
4297
+ this.logger.warning(`DOM processing timed out for ${logPageUrl}`);
3631
4298
  this.logger.warning('🔄 Falling back to minimal DOM state...');
3632
4299
  // Create minimal DOM state for fallback
3633
4300
  const minimal_element_tree = new DOMElementNode(true, null, 'body', '/body', {}, []);
3634
4301
  content = new DOMState(minimal_element_tree, {});
3635
4302
  }
4303
+ finally {
4304
+ await this.validate_page_after_action(page);
4305
+ }
3636
4306
  // Get tabs info
3637
4307
  this.logger.debug('📋 Getting tabs info...');
3638
4308
  const tabs_info = await this.get_tabs_info();
@@ -3645,7 +4315,13 @@ export class BrowserSession {
3645
4315
  screenshot_b64 = await this.take_screenshot();
3646
4316
  }
3647
4317
  catch (error) {
3648
- this.logger.warning(`❌ Screenshot failed for ${page_url}: ${error.message}`);
4318
+ if (error instanceof URLNotAllowedError) {
4319
+ throw error;
4320
+ }
4321
+ this.logger.warning(`❌ Screenshot failed for ${logPageUrl}: ${error.message}`);
4322
+ }
4323
+ finally {
4324
+ await this.validate_page_after_action(page);
3649
4325
  }
3650
4326
  }
3651
4327
  // Get page info and scroll info
@@ -3673,26 +4349,42 @@ export class BrowserSession {
3673
4349
  this.logger.debug('✅ Scroll info completed');
3674
4350
  }
3675
4351
  catch (error) {
4352
+ if (error instanceof URLNotAllowedError) {
4353
+ throw error;
4354
+ }
3676
4355
  this.logger.warning(`Failed to get scroll info: ${error.message}`);
3677
4356
  }
4357
+ finally {
4358
+ await this.validate_page_after_action(page);
4359
+ }
3678
4360
  // Get title
3679
4361
  let title = 'Title unavailable';
3680
4362
  try {
4363
+ await this.validate_page_after_action(page);
3681
4364
  const titlePromise = page.title();
3682
4365
  const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000));
3683
4366
  title = await Promise.race([titlePromise, timeoutPromise]);
3684
4367
  }
3685
4368
  catch (error) {
4369
+ if (error instanceof URLNotAllowedError) {
4370
+ throw error;
4371
+ }
3686
4372
  // Keep default title
3687
4373
  }
4374
+ finally {
4375
+ await this.validate_page_after_action(page);
4376
+ }
3688
4377
  // Check for errors
3689
4378
  const browser_errors = [];
3690
4379
  if (Object.keys(content.selector_map).length === 0) {
3691
- browser_errors.push(`DOM processing timed out for ${page_url} - using minimal state. Basic navigation still available.`);
4380
+ browser_errors.push(`DOM processing timed out for ${logPageUrl} - using minimal state. Basic navigation still available.`);
3692
4381
  }
3693
4382
  // Check if PDF viewer
4383
+ await this.validate_page_after_action(page);
3694
4384
  const is_pdf_viewer = await this._is_pdf_viewer(page);
4385
+ await this.validate_page_after_action(page);
3695
4386
  const pendingNetworkRequests = await this._getPendingNetworkRequests(page);
4387
+ await this.validate_page_after_action(page);
3696
4388
  const paginationButtons = DomService.detect_pagination_buttons(content.selector_map);
3697
4389
  const browser_state = new BrowserStateSummary(content, {
3698
4390
  url: page_url,
@@ -3713,6 +4405,7 @@ export class BrowserSession {
3713
4405
  closed_popup_messages: this._getClosedPopupMessagesSnapshot(),
3714
4406
  });
3715
4407
  this.logger.debug('✅ get_state_summary completed successfully');
4408
+ await this.validate_page_after_action(page);
3716
4409
  return browser_state;
3717
4410
  }
3718
4411
  /**
@@ -3722,6 +4415,7 @@ export class BrowserSession {
3722
4415
  return (url === 'about:blank' ||
3723
4416
  url === 'about:newtab' ||
3724
4417
  url === 'chrome://newtab/' ||
4418
+ url === 'chrome://newtab' ||
3725
4419
  url === 'chrome://new-tab-page/' ||
3726
4420
  url === 'chrome://new-tab-page');
3727
4421
  }
@@ -3747,10 +4441,16 @@ export class BrowserSession {
3747
4441
  // so keep parity with pattern matching default: https-only.
3748
4442
  return protocol.toLowerCase() === 'https:';
3749
4443
  }
4444
+ _domainCollectionHasEntries(value) {
4445
+ return Array.isArray(value)
4446
+ ? value.length > 0
4447
+ : value instanceof Set && value.size > 0;
4448
+ }
3750
4449
  /**
3751
4450
  * Check if page is displaying a PDF
3752
4451
  */
3753
4452
  async _is_pdf_viewer(page) {
4453
+ await this.validate_page_after_action(page);
3754
4454
  try {
3755
4455
  const url = page.url();
3756
4456
  if (url.endsWith('.pdf') || url.includes('.pdf?')) {
@@ -3764,8 +4464,14 @@ export class BrowserSession {
3764
4464
  return is_pdf;
3765
4465
  }
3766
4466
  catch (error) {
4467
+ if (error instanceof URLNotAllowedError) {
4468
+ throw error;
4469
+ }
3767
4470
  return false;
3768
4471
  }
4472
+ finally {
4473
+ await this.validate_page_after_action(page);
4474
+ }
3769
4475
  }
3770
4476
  /**
3771
4477
  * Auto-download PDF if detected and auto-download is enabled
@@ -3775,13 +4481,15 @@ export class BrowserSession {
3775
4481
  if (!downloadsPath || !this._autoDownloadPdfs) {
3776
4482
  return null;
3777
4483
  }
4484
+ await this.validate_page_after_action(page);
3778
4485
  try {
3779
4486
  const is_pdf = await this._is_pdf_viewer(page);
3780
4487
  if (!is_pdf) {
3781
4488
  return null;
3782
4489
  }
3783
4490
  const url = page.url();
3784
- this.logger.info(`📄 PDF detected: ${url}`);
4491
+ const logUrl = BrowserSession._redact_url_for_logging(url);
4492
+ this.logger.info(`📄 PDF detected: ${logUrl}`);
3785
4493
  let pdfFilename = path.basename(url.split('?')[0]);
3786
4494
  if (!pdfFilename || !pdfFilename.toLowerCase().endsWith('.pdf')) {
3787
4495
  const parsed = new URL(url);
@@ -3794,50 +4502,58 @@ export class BrowserSession {
3794
4502
  this.logger.debug(`📄 PDF already downloaded: ${pdfFilename}`);
3795
4503
  return null;
3796
4504
  }
3797
- this.logger.info(`📄 Auto-downloading PDF from: ${url}`);
3798
- const downloadResult = await page.evaluate(async (pdfUrl) => {
3799
- try {
3800
- const response = await fetch(pdfUrl, {
3801
- cache: 'force-cache',
3802
- });
3803
- if (!response.ok) {
3804
- throw new Error(`HTTP error! status: ${response.status}`);
4505
+ this.logger.info(`📄 Auto-downloading PDF from: ${logUrl}`);
4506
+ await this.validate_page_after_action(page);
4507
+ let downloadResult = null;
4508
+ try {
4509
+ downloadResult = await page.evaluate(async (pdfUrl) => {
4510
+ try {
4511
+ const response = await fetch(pdfUrl, {
4512
+ cache: 'force-cache',
4513
+ });
4514
+ if (!response.ok) {
4515
+ throw new Error(`HTTP error! status: ${response.status}`);
4516
+ }
4517
+ const blob = await response.blob();
4518
+ const arrayBuffer = await blob.arrayBuffer();
4519
+ const uint8Array = new Uint8Array(arrayBuffer);
4520
+ const cacheHeader = response.headers.get('x-cache') || '';
4521
+ const fromCache = response.headers.has('age') ||
4522
+ cacheHeader.toLowerCase().includes('hit');
4523
+ return {
4524
+ data: Array.from(uint8Array),
4525
+ fromCache,
4526
+ responseSize: uint8Array.length,
4527
+ };
3805
4528
  }
3806
- const blob = await response.blob();
3807
- const arrayBuffer = await blob.arrayBuffer();
3808
- const uint8Array = new Uint8Array(arrayBuffer);
3809
- const cacheHeader = response.headers.get('x-cache') || '';
3810
- const fromCache = response.headers.has('age') ||
3811
- cacheHeader.toLowerCase().includes('hit');
3812
- return {
3813
- data: Array.from(uint8Array),
3814
- fromCache,
3815
- responseSize: uint8Array.length,
3816
- };
3817
- }
3818
- catch (error) {
3819
- return {
3820
- data: [],
3821
- fromCache: false,
3822
- responseSize: 0,
3823
- error: error instanceof Error ? error.message : 'Unknown fetch error',
3824
- };
3825
- }
3826
- }, url);
4529
+ catch (error) {
4530
+ return {
4531
+ data: [],
4532
+ fromCache: false,
4533
+ responseSize: 0,
4534
+ error: error instanceof Error ? error.message : 'Unknown fetch error',
4535
+ };
4536
+ }
4537
+ }, url);
4538
+ }
4539
+ finally {
4540
+ await this.validate_page_after_action(page);
4541
+ }
3827
4542
  if (downloadResult?.error) {
3828
- this.logger.warning(`⚠️ Failed to auto-download PDF from ${url}: ${downloadResult.error}`);
4543
+ this.logger.warning(`⚠️ Failed to auto-download PDF from ${logUrl}: ${downloadResult.error}`);
3829
4544
  return null;
3830
4545
  }
3831
4546
  if (!downloadResult ||
3832
4547
  !Array.isArray(downloadResult.data) ||
3833
4548
  downloadResult.data.length === 0) {
3834
- this.logger.warning(`⚠️ No data received when downloading PDF from ${url}`);
4549
+ this.logger.warning(`⚠️ No data received when downloading PDF from ${logUrl}`);
3835
4550
  return null;
3836
4551
  }
3837
- await fs.promises.mkdir(downloadsPath, { recursive: true });
4552
+ ensurePrivateDirectoryIfCreated(downloadsPath);
3838
4553
  const uniqueFilename = await BrowserSession.get_unique_filename(downloadsPath, pdfFilename);
3839
4554
  const downloadPath = path.join(downloadsPath, uniqueFilename);
3840
- await fs.promises.writeFile(downloadPath, Buffer.from(downloadResult.data));
4555
+ await fs.promises.writeFile(downloadPath, Buffer.from(downloadResult.data), { mode: 0o600 });
4556
+ chmodPrivateFileBestEffort(downloadPath);
3841
4557
  this.add_downloaded_file(downloadPath);
3842
4558
  const cacheStatus = downloadResult.fromCache
3843
4559
  ? 'from cache'
@@ -3847,6 +4563,9 @@ export class BrowserSession {
3847
4563
  return downloadPath;
3848
4564
  }
3849
4565
  catch (error) {
4566
+ if (error instanceof URLNotAllowedError) {
4567
+ throw error;
4568
+ }
3850
4569
  this.logger.debug(`PDF detection failed: ${error.message}`);
3851
4570
  return null;
3852
4571
  }
@@ -3872,6 +4591,7 @@ export class BrowserSession {
3872
4591
  if (!page) {
3873
4592
  return null;
3874
4593
  }
4594
+ await this.validate_page_after_action(page);
3875
4595
  try {
3876
4596
  // Use XPath to locate the element
3877
4597
  const element_handle = await page
@@ -3899,6 +4619,7 @@ export class BrowserSession {
3899
4619
  if (!page) {
3900
4620
  return null;
3901
4621
  }
4622
+ await this.validate_page_after_action(page);
3902
4623
  try {
3903
4624
  // Use CSS selector to locate the element
3904
4625
  const element_handle = await page.locator(css_selector).elementHandle();
@@ -3927,6 +4648,7 @@ export class BrowserSession {
3927
4648
  if (!page) {
3928
4649
  return null;
3929
4650
  }
4651
+ await this.validate_page_after_action(page);
3930
4652
  try {
3931
4653
  // Build selector: filter by element type and text
3932
4654
  const selector = element_type
@@ -4023,8 +4745,19 @@ export class BrowserSession {
4023
4745
  catch {
4024
4746
  return 'invalid_url';
4025
4747
  }
4026
- if (parsed.protocol === 'data:' || parsed.protocol === 'blob:') {
4027
- return null;
4748
+ const allowedDomains = this.browser_profile.allowed_domains;
4749
+ const hasAllowedDomains = this._domainCollectionHasEntries(allowedDomains);
4750
+ const prohibitedDomains = this.browser_profile.prohibited_domains;
4751
+ const hasProhibitedDomains = this._domainCollectionHasEntries(prohibitedDomains);
4752
+ const hasDomainRestrictions = hasAllowedDomains || hasProhibitedDomains;
4753
+ if (parsed.protocol === 'data:') {
4754
+ return hasDomainRestrictions ? 'opaque_origin_blocked' : null;
4755
+ }
4756
+ if (parsed.protocol === 'blob:') {
4757
+ if (parsed.origin && parsed.origin !== 'null') {
4758
+ return this._get_url_access_denial_reason(parsed.origin);
4759
+ }
4760
+ return hasDomainRestrictions ? 'opaque_origin_blocked' : null;
4028
4761
  }
4029
4762
  if (!parsed.hostname) {
4030
4763
  return 'missing_host';
@@ -4034,50 +4767,44 @@ export class BrowserSession {
4034
4767
  this._is_ip_address_host(parsed.hostname)) {
4035
4768
  return 'ip_address_blocked';
4036
4769
  }
4037
- const allowedDomains = this.browser_profile.allowed_domains;
4038
- if (allowedDomains &&
4039
- ((Array.isArray(allowedDomains) && allowedDomains.length > 0) ||
4040
- (allowedDomains instanceof Set && allowedDomains.size > 0))) {
4041
- if (allowedDomains instanceof Set) {
4042
- if (this._setEntryMatchesUrl(allowedDomains, hostVariant, hostAlt, parsed.protocol)) {
4043
- return null;
4770
+ if (prohibitedDomains && hasProhibitedDomains) {
4771
+ if (prohibitedDomains instanceof Set) {
4772
+ if (this._setEntryMatchesUrl(prohibitedDomains, hostVariant, hostAlt, parsed.protocol)) {
4773
+ return 'in_prohibited_domains';
4044
4774
  }
4045
4775
  }
4046
4776
  else {
4047
- for (const allowedDomain of allowedDomains) {
4777
+ for (const prohibitedDomain of prohibitedDomains) {
4048
4778
  try {
4049
- if (match_url_with_domain_pattern(url, allowedDomain, true)) {
4050
- return null;
4779
+ if (match_url_with_domain_pattern(url, prohibitedDomain, true)) {
4780
+ return 'in_prohibited_domains';
4051
4781
  }
4052
4782
  }
4053
4783
  catch {
4054
- this.logger.warning(`Invalid domain pattern: ${allowedDomain}`);
4784
+ this.logger.warning(`Invalid domain pattern: ${prohibitedDomain}`);
4055
4785
  }
4056
4786
  }
4057
4787
  }
4058
- return 'not_in_allowed_domains';
4059
4788
  }
4060
- const prohibitedDomains = this.browser_profile.prohibited_domains;
4061
- if (prohibitedDomains &&
4062
- ((Array.isArray(prohibitedDomains) && prohibitedDomains.length > 0) ||
4063
- (prohibitedDomains instanceof Set && prohibitedDomains.size > 0))) {
4064
- if (prohibitedDomains instanceof Set) {
4065
- if (this._setEntryMatchesUrl(prohibitedDomains, hostVariant, hostAlt, parsed.protocol)) {
4066
- return 'in_prohibited_domains';
4789
+ if (allowedDomains && hasAllowedDomains) {
4790
+ if (allowedDomains instanceof Set) {
4791
+ if (this._setEntryMatchesUrl(allowedDomains, hostVariant, hostAlt, parsed.protocol)) {
4792
+ return null;
4067
4793
  }
4068
4794
  }
4069
4795
  else {
4070
- for (const prohibitedDomain of prohibitedDomains) {
4796
+ for (const allowedDomain of allowedDomains) {
4071
4797
  try {
4072
- if (match_url_with_domain_pattern(url, prohibitedDomain, true)) {
4073
- return 'in_prohibited_domains';
4798
+ if (match_url_with_domain_pattern(url, allowedDomain, true)) {
4799
+ return null;
4074
4800
  }
4075
4801
  }
4076
4802
  catch {
4077
- this.logger.warning(`Invalid domain pattern: ${prohibitedDomain}`);
4803
+ this.logger.warning(`Invalid domain pattern: ${allowedDomain}`);
4078
4804
  }
4079
4805
  }
4080
4806
  }
4807
+ return 'not_in_allowed_domains';
4081
4808
  }
4082
4809
  return null;
4083
4810
  }
@@ -4090,25 +4817,150 @@ export class BrowserSession {
4090
4817
  }
4091
4818
  return JSON.stringify(value ?? null);
4092
4819
  }
4820
+ _has_url_access_restrictions() {
4821
+ return (this._domainCollectionHasEntries(this.browser_profile.allowed_domains) ||
4822
+ this._domainCollectionHasEntries(this.browser_profile.prohibited_domains) ||
4823
+ Boolean(this.browser_profile.block_ip_addresses));
4824
+ }
4825
+ _sanitize_tab_for_exposure(tab) {
4826
+ if (!this._has_url_access_restrictions()) {
4827
+ return { ...tab };
4828
+ }
4829
+ const denialReason = this._get_url_access_denial_reason(tab.url);
4830
+ if (!denialReason) {
4831
+ return { ...tab };
4832
+ }
4833
+ return {
4834
+ ...tab,
4835
+ url: 'about:blank',
4836
+ title: 'blocked by domain policy',
4837
+ };
4838
+ }
4093
4839
  _assert_url_allowed(url) {
4094
4840
  const denialReason = this._get_url_access_denial_reason(url);
4095
4841
  if (!denialReason) {
4096
4842
  return;
4097
4843
  }
4844
+ const safeUrl = BrowserSession._redact_url_for_logging(url);
4098
4845
  this._recordRecentEvent('navigation_blocked', {
4099
- url,
4846
+ url: safeUrl,
4100
4847
  error_message: denialReason,
4101
4848
  });
4102
4849
  if (denialReason === 'not_in_allowed_domains') {
4103
- throw new URLNotAllowedError(`URL ${url} is not in allowed_domains. Current allowed_domains: ${this._formatDomainCollection(this.browser_profile.allowed_domains)}`);
4850
+ throw new URLNotAllowedError(`URL ${safeUrl} is not in allowed_domains. Current allowed_domains: ${this._formatDomainCollection(this.browser_profile.allowed_domains)}`);
4104
4851
  }
4105
4852
  if (denialReason === 'in_prohibited_domains') {
4106
- throw new URLNotAllowedError(`URL ${url} is blocked by prohibited_domains. Current prohibited_domains: ${this._formatDomainCollection(this.browser_profile.prohibited_domains)}`);
4853
+ throw new URLNotAllowedError(`URL ${safeUrl} is blocked by prohibited_domains. Current prohibited_domains: ${this._formatDomainCollection(this.browser_profile.prohibited_domains)}`);
4107
4854
  }
4108
4855
  if (denialReason === 'ip_address_blocked') {
4109
- throw new URLNotAllowedError(`URL ${url} is blocked because block_ip_addresses=true`);
4856
+ throw new URLNotAllowedError(`URL ${safeUrl} is blocked because block_ip_addresses=true`);
4857
+ }
4858
+ if (denialReason === 'opaque_origin_blocked') {
4859
+ throw new URLNotAllowedError(`URL ${safeUrl} is blocked because its origin cannot be validated against domain restrictions`);
4860
+ }
4861
+ throw new URLNotAllowedError(`URL ${safeUrl} is not allowed (${denialReason})`);
4862
+ }
4863
+ async _rollback_disallowed_navigation(page, blockedUrl, options = {}) {
4864
+ this.logger.warning(`Blocked navigation reached disallowed URL ${BrowserSession._redact_url_for_logging(blockedUrl)}; resetting current tab to about:blank`);
4865
+ try {
4866
+ await page.goto('about:blank', { waitUntil: 'load', timeout: 5000 });
4867
+ }
4868
+ catch (rollbackError) {
4869
+ this.logger.debug(`Failed to reset disallowed navigation to about:blank: ${rollbackError.message}`);
4870
+ }
4871
+ const currentUrl = typeof page.url === 'function' ? page.url() : 'about:blank';
4872
+ if (this._is_new_tab_page(currentUrl)) {
4873
+ this.currentUrl = 'about:blank';
4874
+ this.currentTitle = 'about:blank';
4875
+ const tab = this._tabs[this.currentTabIndex];
4876
+ if (tab) {
4877
+ tab.url = 'about:blank';
4878
+ tab.title = 'about:blank';
4879
+ }
4880
+ this._syncSessionManagerFromTabs();
4881
+ this.cachedBrowserState = null;
4882
+ return;
4883
+ }
4884
+ if (options.replace_on_failure ?? true) {
4885
+ await this._replace_disallowed_current_page(page, blockedUrl);
4886
+ }
4887
+ }
4888
+ async _replace_disallowed_current_page(page, blockedUrl) {
4889
+ const safeUrl = BrowserSession._redact_url_for_logging(blockedUrl);
4890
+ let replacementPage = null;
4891
+ const canCreateReplacement = typeof this.browser_context?.newPage === 'function';
4892
+ if (canCreateReplacement) {
4893
+ try {
4894
+ replacementPage = await this.browser_context.newPage();
4895
+ try {
4896
+ await replacementPage.goto('about:blank', {
4897
+ waitUntil: 'load',
4898
+ timeout: 5000,
4899
+ });
4900
+ }
4901
+ catch (error) {
4902
+ this.logger.debug(`Failed to initialize replacement blank tab after blocked navigation to ${safeUrl}: ${error.message}`);
4903
+ }
4904
+ const replacementUrl = typeof replacementPage.url === 'function'
4905
+ ? replacementPage.url()
4906
+ : 'about:blank';
4907
+ if (!this._is_new_tab_page(replacementUrl)) {
4908
+ await replacementPage.close?.();
4909
+ replacementPage = null;
4910
+ }
4911
+ }
4912
+ catch (error) {
4913
+ this.logger.debug(`Failed to create replacement blank tab after blocked navigation to ${safeUrl}: ${error.message}`);
4914
+ replacementPage = null;
4915
+ }
4916
+ }
4917
+ try {
4918
+ await page.close?.();
4919
+ }
4920
+ catch (error) {
4921
+ this.logger.debug(`Failed to close blocked page after rollback failure: ${error.message}`);
4922
+ }
4923
+ this.currentUrl = 'about:blank';
4924
+ this.currentTitle = 'about:blank';
4925
+ const tab = this._tabs[this.currentTabIndex];
4926
+ if (tab) {
4927
+ tab.url = 'about:blank';
4928
+ tab.title = 'about:blank';
4929
+ }
4930
+ if (this.human_current_page === page) {
4931
+ this.human_current_page = replacementPage;
4932
+ }
4933
+ this._setActivePage(replacementPage);
4934
+ this._syncSessionManagerFromTabs();
4935
+ this.cachedBrowserState = null;
4936
+ }
4937
+ async _assert_page_url_allowed_or_rollback(page, options = {}) {
4938
+ const currentUrl = typeof page.url === 'function' ? page.url() : 'about:blank';
4939
+ try {
4940
+ this._assert_url_allowed(currentUrl);
4941
+ }
4942
+ catch (error) {
4943
+ if (error instanceof URLNotAllowedError) {
4944
+ await this._rollback_disallowed_navigation(page, currentUrl, options);
4945
+ }
4946
+ throw error;
4947
+ }
4948
+ }
4949
+ async _get_disallowed_page_error_after_navigation_error(page, options = {}) {
4950
+ if (!page) {
4951
+ return null;
4952
+ }
4953
+ try {
4954
+ await this._assert_page_url_allowed_or_rollback(page, options);
4955
+ return null;
4956
+ }
4957
+ catch (error) {
4958
+ if (error instanceof URLNotAllowedError) {
4959
+ return error;
4960
+ }
4961
+ this.logger.debug(`Failed to inspect page URL after navigation error: ${error.message}`);
4962
+ return null;
4110
4963
  }
4111
- throw new URLNotAllowedError(`URL ${url} is not allowed (${denialReason})`);
4112
4964
  }
4113
4965
  /**
4114
4966
  * Navigate helper with URL validation
@@ -4182,88 +5034,98 @@ export class BrowserSession {
4182
5034
  if (!element_handle) {
4183
5035
  throw new Error(`Element not found: ${JSON.stringify(element_node)}`);
4184
5036
  }
4185
- // Check if downloads are enabled
4186
- const downloads_path = this.browser_profile.downloads_path;
4187
- if (downloads_path) {
4188
- fs.mkdirSync(downloads_path, { recursive: true });
4189
- // Try to detect file download.
4190
- const download_promise = page.waitForEvent('download', {
4191
- timeout: 5000,
4192
- });
4193
- // Click failures should bubble to the caller.
4194
- try {
4195
- await element_handle.click();
4196
- }
4197
- catch (error) {
4198
- void download_promise.catch(() => undefined);
4199
- throw error;
4200
- }
4201
- let download;
4202
- try {
4203
- download = await download_promise;
5037
+ const validateClickNavigation = async () => {
5038
+ await this._waitForLoad(page, 5000);
5039
+ await this._assert_page_url_allowed_or_rollback(page);
5040
+ await this._syncCurrentTabFromPage(page);
5041
+ if (this.historyStack[this.historyStack.length - 1] !== this.currentUrl) {
5042
+ this.historyStack.push(this.currentUrl);
4204
5043
  }
4205
- catch (error) {
4206
- const message = error instanceof Error ? error.message : String(error);
4207
- const isDownloadTimeout = error instanceof Error &&
4208
- (error.name === 'TimeoutError' ||
4209
- message.toLowerCase().includes('timeout'));
4210
- if (!isDownloadTimeout) {
5044
+ this.cachedBrowserState = null;
5045
+ };
5046
+ try {
5047
+ // Check if downloads are enabled
5048
+ const downloads_path = this.browser_profile.downloads_path;
5049
+ if (downloads_path) {
5050
+ ensurePrivateDirectoryIfCreated(downloads_path);
5051
+ // Try to detect file download.
5052
+ const download_promise = page.waitForEvent('download', {
5053
+ timeout: 5000,
5054
+ });
5055
+ // Click failures should bubble to the caller.
5056
+ try {
5057
+ await element_handle.click();
5058
+ }
5059
+ catch (error) {
5060
+ void download_promise.catch(() => undefined);
4211
5061
  throw error;
4212
5062
  }
4213
- this.logger.debug('No download triggered within timeout. Checking navigation...');
5063
+ let download;
4214
5064
  try {
4215
- await page.waitForLoadState();
5065
+ download = await download_promise;
4216
5066
  }
4217
- catch (e) {
4218
- this.logger.warning(`Navigation check failed: ${e.message}`);
5067
+ catch (error) {
5068
+ const message = error instanceof Error ? error.message : String(error);
5069
+ const isDownloadTimeout = error instanceof Error &&
5070
+ (error.name === 'TimeoutError' ||
5071
+ message.toLowerCase().includes('timeout'));
5072
+ if (!isDownloadTimeout) {
5073
+ throw error;
5074
+ }
5075
+ this.logger.debug('No download triggered within timeout. Checking navigation...');
5076
+ return null;
4219
5077
  }
4220
- return null;
5078
+ // Save the downloaded file.
5079
+ const suggested_filename = download.suggestedFilename();
5080
+ const unique_filename = await BrowserSession.get_unique_filename(downloads_path, suggested_filename);
5081
+ const download_path = path.join(downloads_path, unique_filename);
5082
+ const download_guid = uuid7str();
5083
+ const download_url = typeof download.url === 'function'
5084
+ ? download.url()
5085
+ : (this.currentUrl ?? '');
5086
+ await this._assert_download_url_allowed(download, download_url);
5087
+ await this.event_bus.dispatch(new DownloadStartedEvent({
5088
+ guid: download_guid,
5089
+ url: download_url,
5090
+ suggested_filename,
5091
+ auto_download: false,
5092
+ }));
5093
+ await download.saveAs(download_path);
5094
+ chmodPrivateFileBestEffort(download_path);
5095
+ this.logger.info(`⬇️ Downloaded file to: ${download_path}`);
5096
+ const stats = fs.existsSync(download_path)
5097
+ ? fs.statSync(download_path)
5098
+ : null;
5099
+ await this.event_bus.dispatch(new DownloadProgressEvent({
5100
+ guid: download_guid,
5101
+ received_bytes: stats?.size ?? 0,
5102
+ total_bytes: stats?.size ?? 0,
5103
+ state: 'completed',
5104
+ }));
5105
+ const fileDownloadedResult = await this.event_bus.dispatch(new FileDownloadedEvent({
5106
+ guid: download_guid,
5107
+ url: download_url,
5108
+ path: download_path,
5109
+ file_name: unique_filename,
5110
+ file_size: stats?.size ?? 0,
5111
+ file_type: path.extname(unique_filename).replace('.', '') || null,
5112
+ mime_type: null,
5113
+ auto_download: false,
5114
+ }));
5115
+ if (fileDownloadedResult.handler_results.length === 0) {
5116
+ this.add_downloaded_file(download_path);
5117
+ }
5118
+ return download_path;
4221
5119
  }
4222
- // Save the downloaded file.
4223
- const suggested_filename = download.suggestedFilename();
4224
- const unique_filename = await BrowserSession.get_unique_filename(downloads_path, suggested_filename);
4225
- const download_path = path.join(downloads_path, unique_filename);
4226
- const download_guid = uuid7str();
4227
- const download_url = typeof download.url === 'function'
4228
- ? download.url()
4229
- : (this.currentUrl ?? '');
4230
- await this.event_bus.dispatch(new DownloadStartedEvent({
4231
- guid: download_guid,
4232
- url: download_url,
4233
- suggested_filename,
4234
- auto_download: false,
4235
- }));
4236
- await download.saveAs(download_path);
4237
- this.logger.info(`⬇️ Downloaded file to: ${download_path}`);
4238
- const stats = fs.existsSync(download_path)
4239
- ? fs.statSync(download_path)
4240
- : null;
4241
- await this.event_bus.dispatch(new DownloadProgressEvent({
4242
- guid: download_guid,
4243
- received_bytes: stats?.size ?? 0,
4244
- total_bytes: stats?.size ?? 0,
4245
- state: 'completed',
4246
- }));
4247
- const fileDownloadedResult = await this.event_bus.dispatch(new FileDownloadedEvent({
4248
- guid: download_guid,
4249
- url: download_url,
4250
- path: download_path,
4251
- file_name: unique_filename,
4252
- file_size: stats?.size ?? 0,
4253
- file_type: path.extname(unique_filename).replace('.', '') || null,
4254
- mime_type: null,
4255
- auto_download: false,
4256
- }));
4257
- if (fileDownloadedResult.handler_results.length === 0) {
4258
- this.add_downloaded_file(download_path);
5120
+ else {
5121
+ // No downloads path configured, just click
5122
+ await element_handle.click();
4259
5123
  }
4260
- return download_path;
5124
+ return null;
4261
5125
  }
4262
- else {
4263
- // No downloads path configured, just click
4264
- await element_handle.click();
5126
+ finally {
5127
+ await validateClickNavigation();
4265
5128
  }
4266
- return null;
4267
5129
  }
4268
5130
  /**
4269
5131
  * Remove all highlights from the current page
@@ -4309,6 +5171,9 @@ export class BrowserSession {
4309
5171
  catch (error) {
4310
5172
  this.logger.debug(`Failed to remove highlights: ${error.message}`);
4311
5173
  }
5174
+ finally {
5175
+ await this.validate_page_after_action(page);
5176
+ }
4312
5177
  }
4313
5178
  // region - Trace Recording
4314
5179
  /**
@@ -4329,6 +5194,11 @@ export class BrowserSession {
4329
5194
  * Note: Currently optional as it may cause performance issues in some cases
4330
5195
  */
4331
5196
  async _startContextTracing() {
5197
+ if (this.browser_profile.traces_dir &&
5198
+ this._has_url_access_restrictions()) {
5199
+ this.logger.warning('Skipping trace recording because domain restrictions are active and trace artifacts cannot be URL-filtered.');
5200
+ return;
5201
+ }
4332
5202
  if (this.browser_profile.traces_dir && this.browser_context) {
4333
5203
  try {
4334
5204
  this.logger.debug(`📽️ Starting tracing (will save to: ${this.browser_profile.traces_dir})`);
@@ -4348,6 +5218,10 @@ export class BrowserSession {
4348
5218
  * Save browser trace recording
4349
5219
  */
4350
5220
  async _saveTraceRecording() {
5221
+ if (this.browser_profile.traces_dir &&
5222
+ this._has_url_access_restrictions()) {
5223
+ return;
5224
+ }
4351
5225
  if (this.browser_profile.traces_dir && this.browser_context) {
4352
5226
  try {
4353
5227
  const tracesPath = this.browser_profile.traces_dir;
@@ -4362,8 +5236,10 @@ export class BrowserSession {
4362
5236
  const traceFilename = `BrowserSession_${this.id}.zip`;
4363
5237
  finalTracePath = path.join(tracesPath, traceFilename);
4364
5238
  }
5239
+ ensurePrivateDirectoryIfCreated(path.dirname(finalTracePath));
4365
5240
  this.logger.info(`🎥 Saving browser_context trace to ${finalTracePath}...`);
4366
5241
  await this.browser_context.tracing.stop({ path: finalTracePath });
5242
+ chmodPrivateFileBestEffort(finalTracePath);
4367
5243
  }
4368
5244
  catch (error) {
4369
5245
  this.logger.warning(`Failed to save trace recording: ${error.message}`);
@@ -4608,6 +5484,7 @@ export class BrowserSession {
4608
5484
  * @returns true if successful, false otherwise
4609
5485
  */
4610
5486
  async _forceClosePageViaCdp(pageUrl) {
5487
+ const logPageUrl = BrowserSession._redact_url_for_logging(pageUrl);
4611
5488
  try {
4612
5489
  if (!this.browser_context) {
4613
5490
  throw new Error('Browser context is not set up yet');
@@ -4644,7 +5521,7 @@ export class BrowserSession {
4644
5521
  }
4645
5522
  if (blockedTargetId) {
4646
5523
  // Force close the target
4647
- this.logger.warning(`🪓 Force-closing crashed page target_id=${blockedTargetId} via CDP: ${pageUrl.substring(0, 50)}...`);
5524
+ this.logger.warning(`🪓 Force-closing crashed page target_id=${blockedTargetId} via CDP: ${logPageUrl}`);
4648
5525
  await Promise.race([
4649
5526
  cdpSession.send('Target.closeTarget', {
4650
5527
  targetId: blockedTargetId,
@@ -4654,7 +5531,7 @@ export class BrowserSession {
4654
5531
  return true;
4655
5532
  }
4656
5533
  else {
4657
- this.logger.debug(`❌ Could not find CDP page target_id to force-close: ${pageUrl.substring(0, 50)} (concurrency issues?)`);
5534
+ this.logger.debug(`❌ Could not find CDP page target_id to force-close: ${logPageUrl} (concurrency issues?)`);
4658
5535
  return false;
4659
5536
  }
4660
5537
  }
@@ -4693,8 +5570,14 @@ export class BrowserSession {
4693
5570
  return false;
4694
5571
  }
4695
5572
  const timeout = timeoutMs || this.browser_profile.default_navigation_timeout || 6000;
5573
+ const logUrl = BrowserSession._redact_url_for_logging(url);
5574
+ const denialReason = this._get_url_access_denial_reason(url);
5575
+ if (denialReason) {
5576
+ this.logger.warning(`Skipping recovery reopen for disallowed URL: ${denialReason}`);
5577
+ return false;
5578
+ }
4696
5579
  try {
4697
- this.logger.debug(`🔄 Attempting to reload URL that crashed: ${url.substring(0, 50)}`);
5580
+ this.logger.debug(`🔄 Attempting to reload URL that crashed: ${logUrl}`);
4698
5581
  if (!this.browser_context) {
4699
5582
  throw new Error('Browser context is not set');
4700
5583
  }
@@ -4717,31 +5600,55 @@ export class BrowserSession {
4717
5600
  ]);
4718
5601
  }
4719
5602
  catch (error) {
4720
- this.logger.debug(`⚠️ Attempting to reload previously crashed URL ${url.substring(0, 50)} failed again: ${error.name}`);
5603
+ this.logger.debug(`⚠️ Attempting to reload previously crashed URL ${logUrl} failed again: ${error.name}`);
5604
+ }
5605
+ try {
5606
+ await this._assert_page_url_allowed_or_rollback(newPage, {
5607
+ replace_on_failure: false,
5608
+ });
5609
+ }
5610
+ catch (error) {
5611
+ if (error instanceof URLNotAllowedError) {
5612
+ this.logger.warning(`Skipping recovery reopen after redirect to disallowed URL: ${error.message}`);
5613
+ try {
5614
+ await newPage.close?.();
5615
+ }
5616
+ catch {
5617
+ // Ignore cleanup errors; the page was already reset best effort.
5618
+ }
5619
+ if (this.agent_current_page === newPage) {
5620
+ this.agent_current_page = null;
5621
+ }
5622
+ if (this.human_current_page === newPage) {
5623
+ this.human_current_page = null;
5624
+ }
5625
+ return false;
5626
+ }
5627
+ throw error;
4721
5628
  }
4722
5629
  // Wait a bit for any transient blocking to resolve
4723
5630
  await new Promise((resolve) => setTimeout(resolve, 1000));
4724
5631
  // Check if the reopened page is responsive
4725
5632
  const isResponsive = await this._isPageResponsive(newPage, 2.0);
4726
5633
  if (isResponsive) {
4727
- this.logger.info(`✅ Page recovered and is now responsive after reopening on: ${url.substring(0, 50)}`);
5634
+ this.logger.info(`✅ Page recovered and is now responsive after reopening on: ${logUrl}`);
4728
5635
  return true;
4729
5636
  }
4730
5637
  else {
4731
- this.logger.warning(`⚠️ Reopened page ${url.substring(0, 50)} is still unresponsive`);
5638
+ this.logger.warning(`⚠️ Reopened page ${logUrl} is still unresponsive`);
4732
5639
  // Close the unresponsive page before returning
4733
5640
  try {
4734
5641
  await this._forceClosePageViaCdp(newPage.url());
4735
5642
  }
4736
5643
  catch (error) {
4737
- this.logger.error(`❌ Failed to close crashed page ${url.substring(0, 50)} via CDP: ${error.message} (something is very wrong or system is extremely overloaded)`);
5644
+ this.logger.error(`❌ Failed to close crashed page ${logUrl} via CDP: ${error.message} (something is very wrong or system is extremely overloaded)`);
4738
5645
  }
4739
5646
  this.agent_current_page = null; // Clear reference to closed page
4740
5647
  return false;
4741
5648
  }
4742
5649
  }
4743
5650
  catch (error) {
4744
- this.logger.error(`❌ Retrying crashed page ${url.substring(0, 50)} failed: ${error.message}`);
5651
+ this.logger.error(`❌ Retrying crashed page ${logUrl} failed: ${error.message}`);
4745
5652
  return false;
4746
5653
  }
4747
5654
  }
@@ -4750,7 +5657,7 @@ export class BrowserSession {
4750
5657
  * @param url - The original URL that failed
4751
5658
  */
4752
5659
  async _createBlankFallbackPage(url) {
4753
- this.logger.warning(`⚠️ Resetting to about:blank as fallback because browser is unable to load the original URL without crashing: ${url.substring(0, 50)}`);
5660
+ this.logger.warning(`⚠️ Resetting to about:blank as fallback because browser is unable to load the original URL without crashing: ${BrowserSession._redact_url_for_logging(url)}`);
4754
5661
  // Close any existing broken page
4755
5662
  if (this.agent_current_page && !this.agent_current_page.isClosed()) {
4756
5663
  try {
@@ -4832,7 +5739,7 @@ export class BrowserSession {
4832
5739
  !pageUrl.startsWith('edge://')) {
4833
5740
  try {
4834
5741
  await page.close();
4835
- this.logger.debug(`🪓 Closed page because it has a known crash-causing URL: ${pageUrl.substring(0, 50)}`);
5742
+ this.logger.debug(`🪓 Closed page because it has a known crash-causing URL: ${BrowserSession._redact_url_for_logging(pageUrl)}`);
4836
5743
  }
4837
5744
  catch {
4838
5745
  // Page might already be closed via CDP
@@ -4974,7 +5881,7 @@ export class BrowserSession {
4974
5881
  }
4975
5882
  // Ensure directory exists
4976
5883
  if (!fs.existsSync(userDataDir)) {
4977
- fs.mkdirSync(userDataDir, { recursive: true });
5884
+ ensurePrivateDirectoryIfCreated(userDataDir);
4978
5885
  this.logger.debug(`Created user data directory: ${userDataDir}`);
4979
5886
  }
4980
5887
  return userDataDir;
@@ -5038,9 +5945,10 @@ export class BrowserSession {
5038
5945
  * Create a temporary user data directory
5039
5946
  */
5040
5947
  async _createTempUserDataDir() {
5041
- const osTempDir = os.tmpdir();
5042
- const tempDir = path.join(osTempDir, `browser-use-${Date.now()}-${Math.random().toString(36).slice(2)}`);
5043
- fs.mkdirSync(tempDir, { recursive: true });
5948
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browser-use-user-data-dir-'));
5949
+ if (process.platform !== 'win32') {
5950
+ fs.chmodSync(tempDir, 0o700);
5951
+ }
5044
5952
  return tempDir;
5045
5953
  }
5046
5954
  // endregion
@@ -5055,7 +5963,7 @@ export class BrowserSession {
5055
5963
  }
5056
5964
  // Listen for page events to track which page the user is viewing
5057
5965
  this.browser_context.on?.('page', (page) => {
5058
- this.logger.debug(`New page created: ${page.url?.() || 'about:blank'}`);
5966
+ this.logger.debug(`New page created: ${BrowserSession._redact_url_for_logging(page.url?.() || 'about:blank')}`);
5059
5967
  // Note: 'visibilitychange' is not a standard Playwright page event
5060
5968
  // Visibility tracking would need to be implemented differently
5061
5969
  // (e.g., through page.evaluate polling or browser context events)
@@ -5076,7 +5984,7 @@ export class BrowserSession {
5076
5984
  .evaluate?.(() => document.visibilityState === 'visible')
5077
5985
  .then((isVisible) => {
5078
5986
  if (isVisible) {
5079
- this.logger.debug(`Tab became visible: ${page.url?.() || 'unknown'}`);
5987
+ this.logger.debug(`Tab became visible: ${BrowserSession._redact_url_for_logging(page.url?.() || 'unknown')}`);
5080
5988
  this.human_current_page = page;
5081
5989
  }
5082
5990
  })