agent-browser 0.17.1 → 0.19.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.
package/dist/browser.js CHANGED
@@ -12,7 +12,7 @@ import { getEncryptionKey, isEncryptedPayload, decryptData, ENCRYPTION_KEY_ENV,
12
12
  * Can be overridden via the AGENT_BROWSER_DEFAULT_TIMEOUT environment variable.
13
13
  * Default is 25s, which is below the CLI's 30s IPC read timeout to ensure
14
14
  * Playwright errors are returned before the CLI gives up with EAGAIN.
15
- * CDP and recording contexts use a shorter fixed timeout (10s) and are not affected.
15
+ * Recording contexts use a shorter fixed timeout (10s) and are not affected.
16
16
  */
17
17
  export function getDefaultTimeout() {
18
18
  const envValue = process.env.AGENT_BROWSER_DEFAULT_TIMEOUT;
@@ -24,12 +24,32 @@ export function getDefaultTimeout() {
24
24
  }
25
25
  return 25000;
26
26
  }
27
+ /**
28
+ * Handles boolean env vars and parsing (e.g., "true", "1", "false", "0"),
29
+ * with a default value if not set or invalid
30
+ */
31
+ export function parseBooleanEnvVar(name, defaultValue) {
32
+ const truthyVals = ['1', 'true'];
33
+ const falsyVals = ['0', 'false'];
34
+ if (!Object.hasOwn(process.env, name)) {
35
+ return defaultValue;
36
+ }
37
+ const param = process.env[name].toLowerCase();
38
+ if (truthyVals.includes(param)) {
39
+ return true;
40
+ }
41
+ if (falsyVals.includes(param)) {
42
+ return false;
43
+ }
44
+ return defaultValue;
45
+ }
27
46
  /**
28
47
  * Manages the Playwright browser lifecycle with multiple tabs/windows
29
48
  */
30
49
  export class BrowserManager {
31
50
  browser = null;
32
51
  cdpEndpoint = null; // stores port number or full URL
52
+ resolvedWsUrl = null;
33
53
  isPersistentContext = false;
34
54
  browserbaseSessionId = null;
35
55
  browserbaseApiKey = null;
@@ -37,6 +57,7 @@ export class BrowserManager {
37
57
  browserUseApiKey = null;
38
58
  kernelSessionId = null;
39
59
  kernelApiKey = null;
60
+ browserlessStopUrl = null;
40
61
  contexts = [];
41
62
  pages = [];
42
63
  activePageIndex = 0;
@@ -53,6 +74,17 @@ export class BrowserManager {
53
74
  colorScheme = null;
54
75
  downloadPath = null;
55
76
  allowedDomains = [];
77
+ inspectServer = null;
78
+ stopInspectServer() {
79
+ if (this.inspectServer) {
80
+ this.inspectServer.stop();
81
+ this.inspectServer = null;
82
+ }
83
+ }
84
+ setInspectServer(server) {
85
+ this.stopInspectServer();
86
+ this.inspectServer = server;
87
+ }
56
88
  /**
57
89
  * Set the persistent color scheme preference.
58
90
  * Applied automatically to all new pages and contexts.
@@ -94,6 +126,19 @@ export class BrowserManager {
94
126
  isLaunched() {
95
127
  return this.browser !== null || this.isPersistentContext;
96
128
  }
129
+ getCdpUrl() {
130
+ if (this.resolvedWsUrl)
131
+ return this.resolvedWsUrl;
132
+ if (this.cdpEndpoint?.startsWith('ws://') || this.cdpEndpoint?.startsWith('wss://')) {
133
+ return this.cdpEndpoint;
134
+ }
135
+ try {
136
+ return this.browser?.wsEndpoint?.() ?? null;
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ }
97
142
  /**
98
143
  * Get enhanced snapshot with refs and cache the ref map
99
144
  */
@@ -312,6 +357,40 @@ export class BrowserManager {
312
357
  this.activeFrame = frame;
313
358
  }
314
359
  }
360
+ /**
361
+ * Navigate the active page to a URL and return the resolved URL + title.
362
+ * If the browser is launched but all pages have been closed, a new page is
363
+ * created automatically before navigating (stale-session recovery).
364
+ */
365
+ async navigate(url, options = {}) {
366
+ this.checkDomainAllowed(url);
367
+ await this.ensurePage();
368
+ if (options.headers && Object.keys(options.headers).length > 0) {
369
+ await this.setScopedHeaders(url, options.headers);
370
+ }
371
+ const page = this.getPage();
372
+ await page.goto(url, {
373
+ waitUntil: options.waitUntil ?? 'load',
374
+ });
375
+ return {
376
+ url: page.url(),
377
+ title: await page.title(),
378
+ };
379
+ }
380
+ /**
381
+ * Get the active page URL.
382
+ */
383
+ async getUrl() {
384
+ await this.ensurePage();
385
+ return this.getPage().url();
386
+ }
387
+ /**
388
+ * Get the active page title.
389
+ */
390
+ async getTitle() {
391
+ await this.ensurePage();
392
+ return this.getPage().title();
393
+ }
315
394
  /**
316
395
  * Switch back to main frame
317
396
  */
@@ -719,12 +798,17 @@ export class BrowserManager {
719
798
  * Close a Browserbase session via API
720
799
  */
721
800
  async closeBrowserbaseSession(sessionId, apiKey) {
722
- await fetch(`https://api.browserbase.com/v1/sessions/${sessionId}`, {
723
- method: 'DELETE',
801
+ const response = await fetch(`https://api.browserbase.com/v1/sessions/${sessionId}`, {
802
+ method: 'POST',
724
803
  headers: {
804
+ 'Content-Type': 'application/json',
725
805
  'X-BB-API-Key': apiKey,
726
806
  },
807
+ body: JSON.stringify({ status: 'REQUEST_RELEASE' }),
727
808
  });
809
+ if (!response.ok) {
810
+ throw new Error(`Failed to close Browserbase session: ${response.statusText}`);
811
+ }
728
812
  }
729
813
  /**
730
814
  * Close a Browser Use session via API
@@ -746,35 +830,43 @@ export class BrowserManager {
746
830
  * Close a Kernel session via API
747
831
  */
748
832
  async closeKernelSession(sessionId, apiKey) {
833
+ const headers = {};
834
+ if (apiKey) {
835
+ headers['Authorization'] = `Bearer ${apiKey}`;
836
+ }
749
837
  const response = await fetch(`https://api.onkernel.com/browsers/${sessionId}`, {
750
838
  method: 'DELETE',
751
- headers: {
752
- Authorization: `Bearer ${apiKey}`,
753
- },
839
+ headers,
754
840
  });
755
841
  if (!response.ok) {
756
842
  throw new Error(`Failed to close Kernel session: ${response.statusText}`);
757
843
  }
758
844
  }
845
+ /**
846
+ * Close a Browserless session via its stop URL
847
+ */
848
+ async closeBrowserlessSession(stopUrl) {
849
+ const response = await fetch(stopUrl, {
850
+ method: 'DELETE',
851
+ });
852
+ if (!response.ok) {
853
+ throw new Error(`Failed to close Browserless session: ${response.statusText}`);
854
+ }
855
+ }
759
856
  /**
760
857
  * Connect to Browserbase remote browser via CDP.
761
- * Requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment variables.
858
+ * Requires BROWSERBASE_API_KEY environment variable.
762
859
  */
763
860
  async connectToBrowserbase() {
764
861
  const browserbaseApiKey = process.env.BROWSERBASE_API_KEY;
765
- const browserbaseProjectId = process.env.BROWSERBASE_PROJECT_ID;
766
- if (!browserbaseApiKey || !browserbaseProjectId) {
767
- throw new Error('BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID are required when using browserbase as a provider');
862
+ if (!browserbaseApiKey) {
863
+ throw new Error('BROWSERBASE_API_KEY is required when using browserbase as a provider');
768
864
  }
769
865
  const response = await fetch('https://api.browserbase.com/v1/sessions', {
770
866
  method: 'POST',
771
867
  headers: {
772
- 'Content-Type': 'application/json',
773
868
  'X-BB-API-Key': browserbaseApiKey,
774
869
  },
775
- body: JSON.stringify({
776
- projectId: browserbaseProjectId,
777
- }),
778
870
  });
779
871
  if (!response.ok) {
780
872
  throw new Error(`Failed to create Browserbase session: ${response.statusText}`);
@@ -794,7 +886,7 @@ export class BrowserManager {
794
886
  this.browserbaseSessionId = session.id;
795
887
  this.browserbaseApiKey = browserbaseApiKey;
796
888
  this.browser = browser;
797
- context.setDefaultTimeout(10000);
889
+ context.setDefaultTimeout(getDefaultTimeout());
798
890
  this.contexts.push(context);
799
891
  this.setupContextTracking(context);
800
892
  await this.ensureDomainFilter(context);
@@ -815,12 +907,14 @@ export class BrowserManager {
815
907
  * Returns the profile object if successful.
816
908
  */
817
909
  async findOrCreateKernelProfile(profileName, apiKey) {
910
+ const headers = {};
911
+ if (apiKey) {
912
+ headers['Authorization'] = `Bearer ${apiKey}`;
913
+ }
818
914
  // First, try to get the existing profile
819
915
  const getResponse = await fetch(`https://api.onkernel.com/profiles/${encodeURIComponent(profileName)}`, {
820
916
  method: 'GET',
821
- headers: {
822
- Authorization: `Bearer ${apiKey}`,
823
- },
917
+ headers,
824
918
  });
825
919
  if (getResponse.ok) {
826
920
  // Profile exists, return it
@@ -834,7 +928,7 @@ export class BrowserManager {
834
928
  method: 'POST',
835
929
  headers: {
836
930
  'Content-Type': 'application/json',
837
- Authorization: `Bearer ${apiKey}`,
931
+ ...headers,
838
932
  },
839
933
  body: JSON.stringify({ name: profileName }),
840
934
  });
@@ -845,13 +939,13 @@ export class BrowserManager {
845
939
  }
846
940
  /**
847
941
  * Connect to Kernel remote browser via CDP.
848
- * Requires KERNEL_API_KEY environment variable.
942
+ * Uses KERNEL_API_KEY environment variable for authentication when set.
943
+ * When running inside environments with external credential injection
944
+ * (e.g. Vercel Sandbox credentials brokering), the API key can be omitted
945
+ * and auth headers will be injected at the network layer.
849
946
  */
850
947
  async connectToKernel() {
851
948
  const kernelApiKey = process.env.KERNEL_API_KEY;
852
- if (!kernelApiKey) {
853
- throw new Error('KERNEL_API_KEY is required when using kernel as a provider');
854
- }
855
949
  // Find or create profile if KERNEL_PROFILE_NAME is set
856
950
  const profileName = process.env.KERNEL_PROFILE_NAME;
857
951
  let profileConfig;
@@ -864,12 +958,15 @@ export class BrowserManager {
864
958
  },
865
959
  };
866
960
  }
961
+ const headers = {
962
+ 'Content-Type': 'application/json',
963
+ };
964
+ if (kernelApiKey) {
965
+ headers['Authorization'] = `Bearer ${kernelApiKey}`;
966
+ }
867
967
  const response = await fetch('https://api.onkernel.com/browsers', {
868
968
  method: 'POST',
869
- headers: {
870
- 'Content-Type': 'application/json',
871
- Authorization: `Bearer ${kernelApiKey}`,
872
- },
969
+ headers,
873
970
  body: JSON.stringify({
874
971
  // Kernel browsers are headful by default with stealth mode available
875
972
  // The user can configure these via environment variables if needed
@@ -911,7 +1008,7 @@ export class BrowserManager {
911
1008
  page = pages[0] ?? (await context.newPage());
912
1009
  }
913
1010
  this.kernelSessionId = session.session_id;
914
- this.kernelApiKey = kernelApiKey;
1011
+ this.kernelApiKey = kernelApiKey ?? null;
915
1012
  this.browser = browser;
916
1013
  context.setDefaultTimeout(getDefaultTimeout());
917
1014
  this.contexts.push(context);
@@ -994,6 +1091,82 @@ export class BrowserManager {
994
1091
  throw error;
995
1092
  }
996
1093
  }
1094
+ /**
1095
+ * Connect to Browserless remote browser via CDP.
1096
+ * Requires BROWSERLESS_API_KEY environment variable.
1097
+ */
1098
+ async connectToBrowserless() {
1099
+ const browserlessToken = process.env.BROWSERLESS_API_KEY;
1100
+ if (!browserlessToken) {
1101
+ throw new Error('BROWSERLESS_API_KEY is required when using browserless as a provider');
1102
+ }
1103
+ const supportedBrowsers = ['chromium', 'chrome'];
1104
+ const apiUrl = process.env.BROWSERLESS_API_URL || 'https://production-sfo.browserless.io';
1105
+ const browserType = process.env.BROWSERLESS_BROWSER_TYPE || 'chromium';
1106
+ const ttl = parseInt(process.env.BROWSERLESS_TTL || '300000', 10);
1107
+ const stealth = parseBooleanEnvVar('BROWSERLESS_STEALTH', true);
1108
+ if (!supportedBrowsers.includes(browserType)) {
1109
+ throw new Error(`BROWSERLESS_BROWSER_TYPE "${browserType}" is not supported. Only ${supportedBrowsers.join(', ')} are allowed.`);
1110
+ }
1111
+ const response = await fetch(`${apiUrl}/session?token=${encodeURIComponent(browserlessToken)}`, {
1112
+ method: 'POST',
1113
+ headers: {
1114
+ 'Content-Type': 'application/json',
1115
+ },
1116
+ body: JSON.stringify({
1117
+ ttl,
1118
+ stealth,
1119
+ browser: browserType,
1120
+ }),
1121
+ });
1122
+ if (!response.ok) {
1123
+ throw new Error(`Failed to create Browserless session: ${response.statusText}`);
1124
+ }
1125
+ let session;
1126
+ try {
1127
+ session = (await response.json());
1128
+ }
1129
+ catch (error) {
1130
+ throw new Error(`Failed to parse Browserless session response: ${error instanceof Error ? error.message : String(error)}`);
1131
+ }
1132
+ if (!session.connect || !session.stop) {
1133
+ throw new Error(`Invalid Browserless session response: missing ${!session.connect ? 'connect' : 'stop'}`);
1134
+ }
1135
+ const browser = await chromium.connectOverCDP(session.connect).catch(() => {
1136
+ throw new Error('Failed to connect to Browserless session via CDP');
1137
+ });
1138
+ try {
1139
+ const contexts = browser.contexts();
1140
+ let context;
1141
+ let page;
1142
+ if (contexts.length === 0) {
1143
+ context = await browser.newContext();
1144
+ page = await context.newPage();
1145
+ }
1146
+ else {
1147
+ context = contexts[0];
1148
+ const pages = context.pages();
1149
+ page = pages[0] ?? (await context.newPage());
1150
+ }
1151
+ this.browser = browser;
1152
+ this.browserlessStopUrl = session.stop;
1153
+ context.setDefaultTimeout(getDefaultTimeout());
1154
+ this.contexts.push(context);
1155
+ this.setupContextTracking(context);
1156
+ await this.ensureDomainFilter(context);
1157
+ await this.sanitizeExistingPages([page]);
1158
+ this.pages.push(page);
1159
+ this.activePageIndex = 0;
1160
+ this.setupPageTracking(page);
1161
+ }
1162
+ catch (error) {
1163
+ await this.closeBrowserlessSession(session.stop).catch((sessionError) => {
1164
+ console.error('Failed to close Browserless session during cleanup:', sessionError);
1165
+ });
1166
+ this.browserlessStopUrl = null;
1167
+ throw error;
1168
+ }
1169
+ }
997
1170
  /**
998
1171
  * Launch the browser with the specified options
999
1172
  * If already launched, this is a no-op (browser stays open)
@@ -1080,6 +1253,10 @@ export class BrowserManager {
1080
1253
  await this.connectToKernel();
1081
1254
  return;
1082
1255
  }
1256
+ if (provider === 'browserless') {
1257
+ await this.connectToBrowserless();
1258
+ return;
1259
+ }
1083
1260
  if (this.downloadPath) {
1084
1261
  const resolved = path.resolve(this.downloadPath);
1085
1262
  const stat = statSync(resolved, { throwIfNoEntry: false });
@@ -1174,6 +1351,7 @@ export class BrowserManager {
1174
1351
  ...(this.downloadPath && { downloadsPath: this.downloadPath }),
1175
1352
  });
1176
1353
  this.cdpEndpoint = null;
1354
+ this.resolvedWsUrl = null;
1177
1355
  // Check for auto-load state file (supports encrypted files)
1178
1356
  let storageState = options.storageState ? options.storageState : undefined;
1179
1357
  if (!storageState && options.autoStateFilePath) {
@@ -1265,17 +1443,17 @@ export class BrowserManager {
1265
1443
  }
1266
1444
  else if (/^\d+$/.test(cdpEndpoint)) {
1267
1445
  // Numeric string - treat as port number (handles JSON serialization quirks)
1268
- cdpUrl = `http://localhost:${cdpEndpoint}`;
1446
+ cdpUrl = `http://127.0.0.1:${cdpEndpoint}`;
1269
1447
  }
1270
1448
  else {
1271
1449
  // Unknown format - still try as port for backward compatibility
1272
- cdpUrl = `http://localhost:${cdpEndpoint}`;
1450
+ cdpUrl = `http://127.0.0.1:${cdpEndpoint}`;
1273
1451
  }
1274
1452
  const browser = await chromium
1275
1453
  .connectOverCDP(cdpUrl, { timeout: options?.timeout })
1276
1454
  .catch(() => {
1277
1455
  throw new Error(`Failed to connect via CDP to ${cdpUrl}. ` +
1278
- (cdpUrl.includes('localhost')
1456
+ (cdpUrl.includes('127.0.0.1')
1279
1457
  ? `Make sure the app is running with --remote-debugging-port=${cdpEndpoint}`
1280
1458
  : 'Make sure the remote browser is accessible and the URL is correct.'));
1281
1459
  });
@@ -1293,8 +1471,26 @@ export class BrowserManager {
1293
1471
  // All validation passed - commit state
1294
1472
  this.browser = browser;
1295
1473
  this.cdpEndpoint = cdpEndpoint;
1474
+ let resolvedWs = null;
1475
+ try {
1476
+ resolvedWs = browser.wsEndpoint?.() ?? null;
1477
+ }
1478
+ catch (err) {
1479
+ console.error('[inspect] wsEndpoint() failed:', err);
1480
+ }
1481
+ if (!resolvedWs && (cdpUrl.startsWith('http://') || cdpUrl.startsWith('https://'))) {
1482
+ try {
1483
+ const resp = await fetch(`${cdpUrl}/json/version`);
1484
+ const info = await resp.json();
1485
+ resolvedWs = info.webSocketDebuggerUrl ?? null;
1486
+ }
1487
+ catch (err) {
1488
+ console.error('[inspect] /json/version fetch failed:', err);
1489
+ }
1490
+ }
1491
+ this.resolvedWsUrl = resolvedWs;
1296
1492
  for (const context of contexts) {
1297
- context.setDefaultTimeout(10000);
1493
+ context.setDefaultTimeout(getDefaultTimeout());
1298
1494
  this.contexts.push(context);
1299
1495
  this.setupContextTracking(context);
1300
1496
  await this.ensureDomainFilter(context);
@@ -1510,7 +1706,7 @@ export class BrowserManager {
1510
1706
  * Create a new tab in the current context
1511
1707
  */
1512
1708
  async newTab() {
1513
- if (!this.browser || this.contexts.length === 0) {
1709
+ if (!this.isLaunched() || this.contexts.length === 0) {
1514
1710
  throw new Error('Browser not launched');
1515
1711
  }
1516
1712
  // Invalidate CDP session since we're switching to a new page
@@ -1530,7 +1726,9 @@ export class BrowserManager {
1530
1726
  */
1531
1727
  async newWindow(viewport) {
1532
1728
  if (!this.browser) {
1533
- throw new Error('Browser not launched');
1729
+ throw new Error(this.isPersistentContext
1730
+ ? 'newWindow is not supported in extension (persistent context) mode'
1731
+ : 'Browser not launched');
1534
1732
  }
1535
1733
  const context = await this.browser.newContext({
1536
1734
  viewport: viewport === undefined ? { width: 1280, height: 720 } : viewport,
@@ -1931,8 +2129,8 @@ export class BrowserManager {
1931
2129
  this.recordingTempDir = path.join(os.tmpdir(), `agent-browser-recording-${session}-${Date.now()}`);
1932
2130
  mkdirSync(this.recordingTempDir, { recursive: true });
1933
2131
  this.recordingOutputPath = outputPath;
1934
- // Create a new context with video recording enabled and restored state
1935
- const viewport = { width: 1280, height: 720 };
2132
+ // Reuse the active page viewport when available so recording matches the current layout.
2133
+ const viewport = currentPage?.viewportSize() ?? { width: 1280, height: 720 };
1936
2134
  this.recordingContext = await this.browser.newContext({
1937
2135
  viewport,
1938
2136
  recordVideo: {
@@ -2045,6 +2243,7 @@ export class BrowserManager {
2045
2243
  * Close the browser and clean up
2046
2244
  */
2047
2245
  async close() {
2246
+ this.stopInspectServer();
2048
2247
  // Stop recording if active (saves video)
2049
2248
  if (this.recordingContext) {
2050
2249
  await this.stopRecording();
@@ -2089,12 +2288,18 @@ export class BrowserManager {
2089
2288
  });
2090
2289
  this.browser = null;
2091
2290
  }
2092
- else if (this.kernelSessionId && this.kernelApiKey) {
2093
- await this.closeKernelSession(this.kernelSessionId, this.kernelApiKey).catch((error) => {
2291
+ else if (this.kernelSessionId) {
2292
+ await this.closeKernelSession(this.kernelSessionId, this.kernelApiKey ?? undefined).catch((error) => {
2094
2293
  console.error('Failed to close Kernel session:', error);
2095
2294
  });
2096
2295
  this.browser = null;
2097
2296
  }
2297
+ else if (this.browserlessStopUrl) {
2298
+ await this.closeBrowserlessSession(this.browserlessStopUrl).catch((error) => {
2299
+ console.error('Failed to close Browserless session:', error);
2300
+ });
2301
+ this.browser = null;
2302
+ }
2098
2303
  else if (this.cdpEndpoint !== null) {
2099
2304
  // CDP: only disconnect, don't close external app's pages
2100
2305
  if (this.browser) {
@@ -2118,12 +2323,14 @@ export class BrowserManager {
2118
2323
  this.pages = [];
2119
2324
  this.contexts = [];
2120
2325
  this.cdpEndpoint = null;
2326
+ this.resolvedWsUrl = null;
2121
2327
  this.browserbaseSessionId = null;
2122
2328
  this.browserbaseApiKey = null;
2123
2329
  this.browserUseSessionId = null;
2124
2330
  this.browserUseApiKey = null;
2125
2331
  this.kernelSessionId = null;
2126
2332
  this.kernelApiKey = null;
2333
+ this.browserlessStopUrl = null;
2127
2334
  this.isPersistentContext = false;
2128
2335
  this.activePageIndex = 0;
2129
2336
  this.colorScheme = null;