browser-use 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) 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 +187 -7
  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/filesystem/file-system.js +38 -8
  50. package/dist/integrations/gmail/service.js +30 -6
  51. package/dist/llm/browser-use/chat.js +2 -2
  52. package/dist/llm/codex/auth.d.ts +118 -0
  53. package/dist/llm/codex/auth.js +599 -0
  54. package/dist/llm/codex/chat.d.ts +70 -0
  55. package/dist/llm/codex/chat.js +392 -0
  56. package/dist/llm/codex/index.d.ts +2 -0
  57. package/dist/llm/codex/index.js +2 -0
  58. package/dist/llm/google/chat.js +18 -1
  59. package/dist/logging-config.js +22 -11
  60. package/dist/mcp/client.d.ts +1 -0
  61. package/dist/mcp/client.js +12 -10
  62. package/dist/mcp/redaction.d.ts +3 -0
  63. package/dist/mcp/redaction.js +132 -0
  64. package/dist/mcp/server.d.ts +2 -0
  65. package/dist/mcp/server.js +64 -22
  66. package/dist/screenshots/service.js +25 -2
  67. package/dist/skill-cli/direct.d.ts +4 -1
  68. package/dist/skill-cli/direct.js +260 -64
  69. package/dist/skill-cli/server.d.ts +1 -0
  70. package/dist/skill-cli/server.js +115 -25
  71. package/dist/skill-cli/tunnel.d.ts +1 -0
  72. package/dist/skill-cli/tunnel.js +16 -4
  73. package/dist/sync/auth.js +22 -9
  74. package/dist/telemetry/service.js +21 -2
  75. package/dist/telemetry/views.js +31 -8
  76. package/dist/tokens/custom-pricing.js +2 -2
  77. package/dist/tokens/openrouter-pricing.d.ts +11 -0
  78. package/dist/tokens/openrouter-pricing.js +102 -0
  79. package/dist/tokens/service.js +20 -16
  80. package/dist/utils.d.ts +3 -1
  81. package/dist/utils.js +3 -1
  82. package/package.json +68 -27
@@ -2,7 +2,26 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { ClickCoordinateEvent, ClickElementEvent, CloseTabEvent, FileDownloadedEvent, GetDropdownOptionsEvent, GoBackEvent, GoForwardEvent, NavigateToUrlEvent, RefreshEvent, ScrollEvent, ScrollToTextEvent, SelectDropdownOptionEvent, SendKeysEvent, SwitchTabEvent, TypeTextEvent, UploadFileEvent, WaitEvent, } from '../events.js';
5
+ import { URLNotAllowedError } from '../views.js';
5
6
  import { BaseWatchdog } from './base.js';
7
+ const chmodPrivatePath = (targetPath, mode) => {
8
+ if (process.platform === 'win32') {
9
+ return;
10
+ }
11
+ try {
12
+ fs.chmodSync(targetPath, mode);
13
+ }
14
+ catch {
15
+ /* best effort */
16
+ }
17
+ };
18
+ const ensurePrivateDirectoryIfCreated = (dirPath) => {
19
+ const existed = fs.existsSync(dirPath);
20
+ fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
21
+ if (!existed) {
22
+ chmodPrivatePath(dirPath, 0o700);
23
+ }
24
+ };
6
25
  export class DefaultActionWatchdog extends BaseWatchdog {
7
26
  static LISTENS_TO = [
8
27
  NavigateToUrlEvent,
@@ -158,26 +177,33 @@ export class DefaultActionWatchdog extends BaseWatchdog {
158
177
  return null;
159
178
  }
160
179
  try {
180
+ await this.browser_session.validate_page_after_action(page);
161
181
  const cdpSession = await this.browser_session.get_or_create_cdp_session(page);
182
+ await this.browser_session.validate_page_after_action(page);
162
183
  const result = await cdpSession.send?.('Page.printToPDF', {
163
184
  printBackground: true,
164
185
  preferCSSPageSize: true,
165
186
  });
187
+ await this.browser_session.validate_page_after_action(page);
166
188
  const pdfBase64 = result && typeof result.data === 'string' ? result.data : null;
167
189
  if (!pdfBase64) {
168
190
  return null;
169
191
  }
170
192
  const downloadsPath = this.browser_session.browser_profile.downloads_path || os.tmpdir();
171
- fs.mkdirSync(downloadsPath, { recursive: true });
193
+ ensurePrivateDirectoryIfCreated(downloadsPath);
194
+ await this.browser_session.validate_page_after_action(page);
172
195
  const title = typeof page.title === 'function' ? await page.title() : 'document';
196
+ await this.browser_session.validate_page_after_action(page);
173
197
  const suggestedName = this._sanitizeFilename(`${title || 'document'}.pdf`);
174
198
  const uniqueFilename = await this._getUniqueFilename(downloadsPath, suggestedName);
175
199
  const finalPath = path.join(downloadsPath, uniqueFilename);
176
200
  const content = Buffer.from(pdfBase64, 'base64');
177
- fs.writeFileSync(finalPath, content);
201
+ fs.writeFileSync(finalPath, content, { mode: 0o600 });
202
+ chmodPrivatePath(finalPath, 0o600);
203
+ const pageUrl = typeof page.url === 'function' ? page.url() : '';
178
204
  await this.event_bus.dispatch(new FileDownloadedEvent({
179
205
  guid: null,
180
- url: typeof page.url === 'function' ? page.url() : '',
206
+ url: pageUrl,
181
207
  path: finalPath,
182
208
  file_name: uniqueFilename,
183
209
  file_size: content.length,
@@ -188,6 +214,9 @@ export class DefaultActionWatchdog extends BaseWatchdog {
188
214
  return finalPath;
189
215
  }
190
216
  catch (error) {
217
+ if (error instanceof URLNotAllowedError) {
218
+ throw error;
219
+ }
191
220
  this.browser_session.logger.debug(`[DefaultActionWatchdog] Print-to-PDF failed: ${error.message}`);
192
221
  return null;
193
222
  }
@@ -66,12 +66,16 @@ export declare class DownloadsWatchdog extends BaseWatchdog {
66
66
  private _normalizeCallbackRegistration;
67
67
  private _startCdpDownloadMonitoring;
68
68
  private _stopCdpDownloadMonitoring;
69
+ private _getUrlDenialReason;
69
70
  private _handleNetworkResponse;
70
71
  private _handleNetworkLoadingFinished;
71
72
  private _normalizeHeaders;
72
73
  private _resolveSuggestedFilename;
74
+ private _decodeFilenameCandidate;
73
75
  private _sanitizeFilename;
74
76
  private _inferFileType;
75
77
  private _getUniqueFilename;
78
+ private _isPathContained;
79
+ private _realPathForMissingPath;
76
80
  }
77
81
  export {};
@@ -2,6 +2,24 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { BrowserConnectedEvent, BrowserLaunchEvent, BrowserStateRequestEvent, BrowserStoppedEvent, DownloadProgressEvent, DownloadStartedEvent, FileDownloadedEvent, NavigationCompleteEvent, TabClosedEvent, TabCreatedEvent, } from '../events.js';
4
4
  import { BaseWatchdog } from './base.js';
5
+ const chmodPrivatePath = (targetPath, mode) => {
6
+ if (process.platform === 'win32') {
7
+ return;
8
+ }
9
+ try {
10
+ fs.chmodSync(targetPath, mode);
11
+ }
12
+ catch {
13
+ /* best effort */
14
+ }
15
+ };
16
+ const ensurePrivateDirectoryIfCreated = (dirPath) => {
17
+ const existed = fs.existsSync(dirPath);
18
+ fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
19
+ if (!existed) {
20
+ chmodPrivatePath(dirPath, 0o700);
21
+ }
22
+ };
5
23
  export class DownloadsWatchdog extends BaseWatchdog {
6
24
  static LISTENS_TO = [
7
25
  BrowserConnectedEvent,
@@ -36,7 +54,7 @@ export class DownloadsWatchdog extends BaseWatchdog {
36
54
  if (!downloadsPath) {
37
55
  return;
38
56
  }
39
- fs.mkdirSync(downloadsPath, { recursive: true });
57
+ ensurePrivateDirectoryIfCreated(downloadsPath);
40
58
  }
41
59
  async on_BrowserStateRequestEvent(event) {
42
60
  const activeTab = this.browser_session.active_tab;
@@ -252,6 +270,26 @@ export class DownloadsWatchdog extends BaseWatchdog {
252
270
  this._cdpSession = null;
253
271
  }
254
272
  }
273
+ _getUrlDenialReason(url) {
274
+ const session = this.browser_session;
275
+ if (typeof session._get_url_access_denial_reason === 'function') {
276
+ try {
277
+ return session._get_url_access_denial_reason(url);
278
+ }
279
+ catch {
280
+ return 'blocked';
281
+ }
282
+ }
283
+ if (typeof session._is_url_allowed === 'function') {
284
+ try {
285
+ return session._is_url_allowed(url) ? null : 'blocked';
286
+ }
287
+ catch {
288
+ return 'blocked';
289
+ }
290
+ }
291
+ return null;
292
+ }
255
293
  async _handleNetworkResponse(payload) {
256
294
  const requestId = String(payload?.requestId ?? '');
257
295
  if (!requestId) {
@@ -273,6 +311,12 @@ export class DownloadsWatchdog extends BaseWatchdog {
273
311
  if (!isPdf && !isAttachment && !isBinary) {
274
312
  return;
275
313
  }
314
+ const denialReason = this._getUrlDenialReason(url);
315
+ if (denialReason) {
316
+ this._detectedDownloadUrls.add(url);
317
+ this.browser_session.logger.warning(`[DownloadsWatchdog] Blocked downloadable network response by domain policy: ${denialReason}`);
318
+ return;
319
+ }
276
320
  this._detectedDownloadUrls.add(url);
277
321
  const suggestedFilename = this._resolveSuggestedFilename(contentDisposition, url);
278
322
  const guid = `cdp-${requestId}`;
@@ -328,13 +372,18 @@ export class DownloadsWatchdog extends BaseWatchdog {
328
372
  if (!body) {
329
373
  return;
330
374
  }
331
- fs.mkdirSync(downloadsPath, { recursive: true });
375
+ ensurePrivateDirectoryIfCreated(downloadsPath);
332
376
  const uniqueFilename = await this._getUniqueFilename(downloadsPath, metadata.suggested_filename);
333
377
  const filePath = path.join(downloadsPath, uniqueFilename);
378
+ if (!this._isPathContained(filePath, downloadsPath)) {
379
+ this.browser_session.logger.debug(`[DownloadsWatchdog] Refusing to write download outside downloads_path: ${filePath}`);
380
+ return;
381
+ }
334
382
  const content = responseBody?.base64Encoded
335
383
  ? Buffer.from(body, 'base64')
336
384
  : Buffer.from(body, 'utf8');
337
- fs.writeFileSync(filePath, content);
385
+ fs.writeFileSync(filePath, content, { mode: 0o600 });
386
+ chmodPrivatePath(filePath, 0o600);
338
387
  await this.event_bus.dispatch(new FileDownloadedEvent({
339
388
  guid: metadata.guid,
340
389
  url: metadata.url,
@@ -362,7 +411,7 @@ export class DownloadsWatchdog extends BaseWatchdog {
362
411
  _resolveSuggestedFilename(contentDisposition, url) {
363
412
  const filenameMatch = /filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i.exec(contentDisposition);
364
413
  const fromHeader = filenameMatch?.[1] || filenameMatch?.[2] || '';
365
- const candidate = decodeURIComponent(fromHeader || '').trim();
414
+ const candidate = this._decodeFilenameCandidate(fromHeader).trim();
366
415
  if (candidate) {
367
416
  return this._sanitizeFilename(candidate);
368
417
  }
@@ -378,9 +427,29 @@ export class DownloadsWatchdog extends BaseWatchdog {
378
427
  }
379
428
  return 'download';
380
429
  }
430
+ _decodeFilenameCandidate(filename) {
431
+ if (!filename) {
432
+ return '';
433
+ }
434
+ try {
435
+ return decodeURIComponent(filename);
436
+ }
437
+ catch {
438
+ return filename;
439
+ }
440
+ }
381
441
  _sanitizeFilename(filename) {
382
- const sanitized = filename
383
- .replace(/[\\/:*?"<>|]+/g, '_')
442
+ const basename = filename
443
+ .replace(/\0/g, '')
444
+ .replace(/\\/g, '/')
445
+ .split('/')
446
+ .pop()
447
+ ?.trim();
448
+ if (!basename || basename === '.' || basename === '..') {
449
+ return 'download';
450
+ }
451
+ const sanitized = basename
452
+ .replace(/[:*?"<>|]+/g, '_')
384
453
  .replace(/\s+/g, ' ')
385
454
  .trim();
386
455
  return sanitized || 'download';
@@ -396,9 +465,10 @@ export class DownloadsWatchdog extends BaseWatchdog {
396
465
  return null;
397
466
  }
398
467
  async _getUniqueFilename(directory, filename) {
399
- const ext = path.extname(filename);
400
- const basename = ext ? filename.slice(0, -ext.length) : filename;
401
- let candidate = filename || 'download';
468
+ const safeFilename = this._sanitizeFilename(filename);
469
+ const ext = path.extname(safeFilename);
470
+ const basename = ext ? safeFilename.slice(0, -ext.length) : safeFilename;
471
+ let candidate = safeFilename || 'download';
402
472
  let counter = 1;
403
473
  while (fs.existsSync(path.join(directory, candidate))) {
404
474
  candidate = `${basename || 'download'}_${counter}${ext}`;
@@ -406,4 +476,30 @@ export class DownloadsWatchdog extends BaseWatchdog {
406
476
  }
407
477
  return candidate;
408
478
  }
479
+ _isPathContained(filePath, directory) {
480
+ const realDirectory = this._realPathForMissingPath(directory);
481
+ const realFilePath = this._realPathForMissingPath(filePath);
482
+ const relative = path.relative(realDirectory, realFilePath);
483
+ return (relative === '' ||
484
+ (relative !== '' &&
485
+ !relative.startsWith('..') &&
486
+ !path.isAbsolute(relative)));
487
+ }
488
+ _realPathForMissingPath(inputPath) {
489
+ const resolvedPath = path.resolve(inputPath);
490
+ if (fs.existsSync(resolvedPath)) {
491
+ return fs.realpathSync.native(resolvedPath);
492
+ }
493
+ const missingParts = [];
494
+ let existingParent = resolvedPath;
495
+ while (!fs.existsSync(existingParent)) {
496
+ const parent = path.dirname(existingParent);
497
+ if (parent === existingParent) {
498
+ return resolvedPath;
499
+ }
500
+ missingParts.unshift(path.basename(existingParent));
501
+ existingParent = parent;
502
+ }
503
+ return path.join(fs.realpathSync.native(existingParent), ...missingParts);
504
+ }
409
505
  }
@@ -15,5 +15,6 @@ export declare class HarRecordingWatchdog extends BaseWatchdog {
15
15
  private _resolveAndPrepareHarPath;
16
16
  private _startCdpCaptureIfNeeded;
17
17
  private _writeHarFallbackIfNeeded;
18
+ private _getUrlDenialReason;
18
19
  private _teardownCapture;
19
20
  }
@@ -32,6 +32,31 @@ const normalizeHeaders = (input) => {
32
32
  }
33
33
  return {};
34
34
  };
35
+ const chmodPrivatePath = (targetPath, mode) => {
36
+ if (process.platform === 'win32') {
37
+ return;
38
+ }
39
+ try {
40
+ fs.chmodSync(targetPath, mode);
41
+ }
42
+ catch {
43
+ /* best effort */
44
+ }
45
+ };
46
+ const ensurePrivateDirectoryIfCreated = (dirPath) => {
47
+ const existed = fs.existsSync(dirPath);
48
+ fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
49
+ if (!existed) {
50
+ chmodPrivatePath(dirPath, 0o700);
51
+ }
52
+ };
53
+ const writePrivateFile = (filePath, contents) => {
54
+ fs.writeFileSync(filePath, contents, {
55
+ encoding: 'utf-8',
56
+ mode: 0o600,
57
+ });
58
+ chmodPrivatePath(filePath, 0o600);
59
+ };
35
60
  export class HarRecordingWatchdog extends BaseWatchdog {
36
61
  static LISTENS_TO = [
37
62
  BrowserStartEvent,
@@ -74,6 +99,7 @@ export class HarRecordingWatchdog extends BaseWatchdog {
74
99
  return;
75
100
  }
76
101
  try {
102
+ chmodPrivatePath(resolvedPath, 0o600);
77
103
  const stat = fs.statSync(resolvedPath);
78
104
  if (stat.size === 0) {
79
105
  await this.event_bus.dispatch(new BrowserErrorEvent({
@@ -113,7 +139,7 @@ export class HarRecordingWatchdog extends BaseWatchdog {
113
139
  if (!resolvedPath) {
114
140
  return null;
115
141
  }
116
- fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
142
+ ensurePrivateDirectoryIfCreated(path.dirname(resolvedPath));
117
143
  this.browser_session.browser_profile.config.record_har_path = resolvedPath;
118
144
  return resolvedPath;
119
145
  }
@@ -132,6 +158,11 @@ export class HarRecordingWatchdog extends BaseWatchdog {
132
158
  if (!requestId || !url.toLowerCase().startsWith('https://')) {
133
159
  return;
134
160
  }
161
+ const denialReason = this._getUrlDenialReason(url);
162
+ if (denialReason) {
163
+ this.browser_session.logger.debug(`[HarRecordingWatchdog] Skipping HAR entry blocked by domain policy: ${denialReason}`);
164
+ return;
165
+ }
135
166
  const tsRequest = typeof payload?.timestamp === 'number' ? payload.timestamp : null;
136
167
  this._entries.set(requestId, {
137
168
  request_id: requestId,
@@ -292,8 +323,29 @@ export class HarRecordingWatchdog extends BaseWatchdog {
292
323
  },
293
324
  };
294
325
  const tempPath = `${resolvedPath}.tmp`;
295
- fs.writeFileSync(tempPath, JSON.stringify(harObject, null, 2), 'utf-8');
326
+ writePrivateFile(tempPath, JSON.stringify(harObject, null, 2));
296
327
  fs.renameSync(tempPath, resolvedPath);
328
+ chmodPrivatePath(resolvedPath, 0o600);
329
+ }
330
+ _getUrlDenialReason(url) {
331
+ const session = this.browser_session;
332
+ if (typeof session._get_url_access_denial_reason === 'function') {
333
+ try {
334
+ return session._get_url_access_denial_reason(url);
335
+ }
336
+ catch {
337
+ return 'blocked';
338
+ }
339
+ }
340
+ if (typeof session._is_url_allowed === 'function') {
341
+ try {
342
+ return session._is_url_allowed(url) ? null : 'blocked';
343
+ }
344
+ catch {
345
+ return 'blocked';
346
+ }
347
+ }
348
+ return null;
297
349
  }
298
350
  async _teardownCapture() {
299
351
  if (!this._cdpSession) {
@@ -4,5 +4,10 @@ export declare class PermissionsWatchdog extends BaseWatchdog {
4
4
  static LISTENS_TO: (typeof BrowserConnectedEvent)[];
5
5
  static EMITS: (typeof BrowserErrorEvent)[];
6
6
  on_BrowserConnectedEvent(): Promise<void>;
7
+ private _grantScopedPermissions;
8
+ private _dispatchPermissionError;
9
+ private _getScopedPermissionOrigins;
10
+ private _domainCollectionToStrings;
11
+ private _originsForAllowedPattern;
7
12
  private _grantPermissionsViaCdp;
8
13
  }
@@ -8,6 +8,11 @@ export class PermissionsWatchdog extends BaseWatchdog {
8
8
  if (!Array.isArray(permissions) || permissions.length === 0) {
9
9
  return;
10
10
  }
11
+ const scopedOrigins = this._getScopedPermissionOrigins();
12
+ if (scopedOrigins) {
13
+ await this._grantScopedPermissions(permissions, scopedOrigins);
14
+ return;
15
+ }
11
16
  let cdpError = null;
12
17
  try {
13
18
  const grantedWithCdp = await this._grantPermissionsViaCdp(permissions);
@@ -49,16 +54,114 @@ export class PermissionsWatchdog extends BaseWatchdog {
49
54
  }));
50
55
  }
51
56
  }
52
- async _grantPermissionsViaCdp(permissions) {
57
+ async _grantScopedPermissions(permissions, origins) {
58
+ if (origins.length === 0) {
59
+ this.browser_session.logger.debug('[PermissionsWatchdog] Domain restrictions are active; skipping global permission grants because no concrete allowed origins can be resolved.');
60
+ return;
61
+ }
62
+ const context = this.browser_session.browser_context;
63
+ for (const origin of origins) {
64
+ let cdpError = null;
65
+ try {
66
+ const grantedWithCdp = await this._grantPermissionsViaCdp(permissions, origin);
67
+ if (grantedWithCdp) {
68
+ continue;
69
+ }
70
+ }
71
+ catch (error) {
72
+ cdpError = error;
73
+ }
74
+ if (!context?.grantPermissions) {
75
+ if (cdpError) {
76
+ await this._dispatchPermissionError(permissions, 'cdp', cdpError, origin);
77
+ }
78
+ continue;
79
+ }
80
+ try {
81
+ await context.grantPermissions(permissions, { origin });
82
+ }
83
+ catch (error) {
84
+ await this._dispatchPermissionError(permissions, 'playwright', error, origin, cdpError);
85
+ }
86
+ }
87
+ }
88
+ async _dispatchPermissionError(permissions, mode, error, origin, cdpError) {
89
+ await this.event_bus.dispatch(new BrowserErrorEvent({
90
+ error_type: 'PermissionsWatchdogError',
91
+ message: error.message ?? 'Failed to grant permissions',
92
+ details: {
93
+ permissions,
94
+ cdp_error: cdpError?.message ?? null,
95
+ mode,
96
+ origin: origin ?? null,
97
+ },
98
+ }));
99
+ }
100
+ _getScopedPermissionOrigins() {
101
+ const session = this.browser_session;
102
+ const hasRestrictions = typeof session._has_url_access_restrictions === 'function'
103
+ ? session._has_url_access_restrictions()
104
+ : false;
105
+ if (!hasRestrictions) {
106
+ return null;
107
+ }
108
+ const allowedDomains = this._domainCollectionToStrings(this.browser_session.browser_profile.allowed_domains);
109
+ const origins = new Set();
110
+ for (const pattern of allowedDomains) {
111
+ for (const origin of this._originsForAllowedPattern(pattern)) {
112
+ const denialReason = typeof session._get_url_access_denial_reason === 'function'
113
+ ? session._get_url_access_denial_reason(origin)
114
+ : null;
115
+ if (!denialReason) {
116
+ origins.add(origin);
117
+ }
118
+ }
119
+ }
120
+ return [...origins];
121
+ }
122
+ _domainCollectionToStrings(value) {
123
+ if (value instanceof Set) {
124
+ return [...value].filter((item) => typeof item === 'string');
125
+ }
126
+ if (Array.isArray(value)) {
127
+ return value.filter((item) => typeof item === 'string');
128
+ }
129
+ return [];
130
+ }
131
+ _originsForAllowedPattern(pattern) {
132
+ const trimmed = pattern.trim();
133
+ if (!trimmed || trimmed.includes('*')) {
134
+ return [];
135
+ }
136
+ try {
137
+ if (trimmed.includes('://')) {
138
+ const parsed = new URL(trimmed);
139
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
140
+ return [];
141
+ }
142
+ return [parsed.origin];
143
+ }
144
+ const parsed = new URL(`https://${trimmed}`);
145
+ return [parsed.origin];
146
+ }
147
+ catch {
148
+ return [];
149
+ }
150
+ }
151
+ async _grantPermissionsViaCdp(permissions, origin) {
53
152
  const browser = this.browser_session.browser;
54
153
  if (!browser?.newBrowserCDPSession) {
55
154
  return false;
56
155
  }
57
156
  const cdpSession = await browser.newBrowserCDPSession();
58
157
  try {
59
- await cdpSession.send?.('Browser.grantPermissions', {
158
+ const params = {
60
159
  permissions,
61
- });
160
+ };
161
+ if (origin) {
162
+ params.origin = origin;
163
+ }
164
+ await cdpSession.send?.('Browser.grantPermissions', params);
62
165
  return true;
63
166
  }
64
167
  finally {
@@ -8,12 +8,14 @@ export declare class RecordingWatchdog extends BaseWatchdog {
8
8
  private _cdpScreencastHandler;
9
9
  private _cdpScreencastPath;
10
10
  private _cdpScreencastStream;
11
+ private _warnedDomainPolicyRecordingDisabled;
11
12
  on_BrowserConnectedEvent(): Promise<void>;
12
13
  on_BrowserStopEvent(): Promise<void>;
13
14
  on_BrowserStoppedEvent(): Promise<void>;
14
15
  on_AgentFocusChangedEvent(event: AgentFocusChangedEvent): Promise<void>;
15
16
  on_TabCreatedEvent(): Promise<void>;
16
17
  protected onDetached(): void;
18
+ private _recordingDisabledByDomainPolicy;
17
19
  private _prepareVideoDirectory;
18
20
  private _startTracingIfConfigured;
19
21
  private _stopTracingIfStarted;
@@ -2,6 +2,24 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { AgentFocusChangedEvent, BrowserConnectedEvent, BrowserErrorEvent, BrowserStopEvent, BrowserStoppedEvent, TabCreatedEvent, } from '../events.js';
4
4
  import { BaseWatchdog } from './base.js';
5
+ const chmodPrivatePath = (targetPath, mode) => {
6
+ if (process.platform === 'win32') {
7
+ return;
8
+ }
9
+ try {
10
+ fs.chmodSync(targetPath, mode);
11
+ }
12
+ catch {
13
+ /* best effort */
14
+ }
15
+ };
16
+ const ensurePrivateDirectoryIfCreated = (dirPath) => {
17
+ const existed = fs.existsSync(dirPath);
18
+ fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
19
+ if (!existed) {
20
+ chmodPrivatePath(dirPath, 0o700);
21
+ }
22
+ };
5
23
  export class RecordingWatchdog extends BaseWatchdog {
6
24
  static LISTENS_TO = [
7
25
  BrowserConnectedEvent,
@@ -16,7 +34,11 @@ export class RecordingWatchdog extends BaseWatchdog {
16
34
  _cdpScreencastHandler = null;
17
35
  _cdpScreencastPath = null;
18
36
  _cdpScreencastStream = null;
37
+ _warnedDomainPolicyRecordingDisabled = false;
19
38
  async on_BrowserConnectedEvent() {
39
+ if (this._recordingDisabledByDomainPolicy()) {
40
+ return;
41
+ }
20
42
  this._prepareVideoDirectory();
21
43
  this._attachVideoListenersToKnownPages();
22
44
  await this._startCdpScreencastIfConfigured();
@@ -30,6 +52,9 @@ export class RecordingWatchdog extends BaseWatchdog {
30
52
  this._detachVideoListeners();
31
53
  }
32
54
  async on_AgentFocusChangedEvent(event) {
55
+ if (this._recordingDisabledByDomainPolicy()) {
56
+ return;
57
+ }
33
58
  this._attachVideoListenersToKnownPages();
34
59
  await this._startCdpScreencastIfConfigured();
35
60
  if (!this._traceStarted) {
@@ -38,6 +63,9 @@ export class RecordingWatchdog extends BaseWatchdog {
38
63
  this.browser_session.logger.debug(`[RecordingWatchdog] Focus changed to ${event.target_id}; tracing remains active`);
39
64
  }
40
65
  async on_TabCreatedEvent() {
66
+ if (this._recordingDisabledByDomainPolicy()) {
67
+ return;
68
+ }
41
69
  this._attachVideoListenersToKnownPages();
42
70
  await this._startCdpScreencastIfConfigured();
43
71
  }
@@ -45,13 +73,32 @@ export class RecordingWatchdog extends BaseWatchdog {
45
73
  void this._stopCdpScreencastIfStarted();
46
74
  this._detachVideoListeners();
47
75
  }
76
+ _recordingDisabledByDomainPolicy() {
77
+ const hasRestrictions = typeof this.browser_session._has_url_access_restrictions ===
78
+ 'function'
79
+ ? this.browser_session._has_url_access_restrictions()
80
+ : false;
81
+ if (!hasRestrictions) {
82
+ return false;
83
+ }
84
+ const hasRecordingConfigured = Boolean(this.browser_session.browser_profile.traces_dir ||
85
+ this.browser_session.browser_profile.config.record_video_dir);
86
+ if (!hasRecordingConfigured) {
87
+ return false;
88
+ }
89
+ if (!this._warnedDomainPolicyRecordingDisabled) {
90
+ this.browser_session.logger.warning('[RecordingWatchdog] Skipping trace/video recording because domain restrictions are active and recording artifacts cannot be URL-filtered.');
91
+ this._warnedDomainPolicyRecordingDisabled = true;
92
+ }
93
+ return true;
94
+ }
48
95
  _prepareVideoDirectory() {
49
96
  const configuredPath = this.browser_session.browser_profile.config.record_video_dir;
50
97
  if (typeof configuredPath !== 'string' || configuredPath.trim() === '') {
51
98
  return;
52
99
  }
53
100
  const resolvedPath = path.resolve(configuredPath);
54
- fs.mkdirSync(resolvedPath, { recursive: true });
101
+ ensurePrivateDirectoryIfCreated(resolvedPath);
55
102
  this.browser_session.browser_profile.config.record_video_dir = resolvedPath;
56
103
  }
57
104
  async _startTracingIfConfigured() {
@@ -171,7 +218,11 @@ export class RecordingWatchdog extends BaseWatchdog {
171
218
  }
172
219
  const session = (await this.browser_session.get_or_create_cdp_session(page));
173
220
  const filePath = path.join(configuredPath, `${Date.now()}-${this.browser_session.id.slice(-6)}.cdp-screencast.ndjson`);
174
- const stream = fs.createWriteStream(filePath, { flags: 'a' });
221
+ const stream = fs.createWriteStream(filePath, {
222
+ flags: 'a',
223
+ mode: 0o600,
224
+ });
225
+ chmodPrivatePath(filePath, 0o600);
175
226
  const handler = (payload) => {
176
227
  const frameData = typeof payload?.data === 'string' ? payload.data : '';
177
228
  if (frameData && this._cdpScreencastStream) {
@@ -239,6 +290,7 @@ export class RecordingWatchdog extends BaseWatchdog {
239
290
  if (this._cdpScreencastPath &&
240
291
  fs.existsSync(this._cdpScreencastPath) &&
241
292
  fs.statSync(this._cdpScreencastPath).size > 0) {
293
+ chmodPrivatePath(this._cdpScreencastPath, 0o600);
242
294
  this.browser_session.add_downloaded_file(this._cdpScreencastPath);
243
295
  }
244
296
  this._cdpScreencastSession = null;
@@ -7,4 +7,5 @@ export declare class SecurityWatchdog extends BaseWatchdog {
7
7
  on_NavigationCompleteEvent(event: NavigationCompleteEvent): Promise<void>;
8
8
  on_TabCreatedEvent(event: TabCreatedEvent): Promise<void>;
9
9
  private _getUrlDenialReason;
10
+ private _isActiveTarget;
10
11
  }