remote-agent-browser 1.1.0 → 1.3.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/README.md CHANGED
@@ -3,6 +3,23 @@
3
3
  Run [agent-browser](https://github.com/vercel-labs/agent-browser) in the cloud
4
4
  with an isolated [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox).
5
5
 
6
+ ## Benchmark
7
+
8
+ Loading and taking an interactive snapshot of 100 HTTPS pages in a fresh,
9
+ two-vCPU Vercel Sandbox:
10
+
11
+ | Metric | Result |
12
+ | --- | ---: |
13
+ | Execution time (100 pages) | 8.01s |
14
+ | Memory (peak, 100 pages) | 461.6MB PSS |
15
+
16
+ Execution time includes Sandbox creation. The published time is the median and
17
+ memory is the maximum from three successful runs using Chromium 151,
18
+ agent-browser 0.33.2, and `https://example.com/` with a unique query per page.
19
+ Each page must produce the expected content and interactive snapshot references.
20
+
21
+ See [the benchmark methodology](./benchmarks/BENCHMARK.md) for details.
22
+
6
23
  ## Install
7
24
 
8
25
  ```bash
@@ -31,6 +48,27 @@ references. `close()` closes Chromium and stops the Sandbox.
31
48
  Authentication is automatic when running on Vercel through
32
49
  `VERCEL_OIDC_TOKEN`.
33
50
 
51
+ To run browsers in another team/project, supply explicit short-lived credentials:
52
+
53
+ ```ts
54
+ const browser = AgentBrowser.session({
55
+ id: `preview:${chatId}`,
56
+ resolveSandboxCredentials: async () => ({
57
+ teamId: resourceTeamId,
58
+ projectId: resourceProjectId,
59
+ token: await getShortLivedTeamToken(),
60
+ }),
61
+ })
62
+ ```
63
+
64
+ The same option works with `AgentBrowser.create()`. Keep the team and project
65
+ fixed for the handle's lifetime; refresh only the token. The resolver is lazy
66
+ for named sessions and runs before creation, cached-runtime command/file access,
67
+ observation/keepalive, and cleanup. A resolver failure rejects the operation;
68
+ commands do not fall back to cached credentials. Returning `undefined` preserves
69
+ deployment authentication. Credentials are not passed into browser command env.
70
+
71
+
34
72
  For local development, link a Vercel project and pull its environment:
35
73
 
36
74
  ```bash
@@ -102,6 +140,25 @@ await browser.exec('find', {
102
140
  })
103
141
  ```
104
142
 
143
+ ### `browser.shell(command, options?)`
144
+
145
+ Use this when forwarding a Bash-tool command or composing `agent-browser` with
146
+ normal shell utilities. The command runs verbatim, so quoting, pipes,
147
+ redirection, and control operators keep their shell semantics. Prefer `exec()`
148
+ or `run()` for browser commands that do not need a shell. Every `agent-browser`
149
+ invocation inherits the client's CLI session through `AGENT_BROWSER_SESSION`.
150
+
151
+ ```ts
152
+ const result = await browser.shell(
153
+ 'agent-browser read "https://example.com" | grep -io "example" | wc -l',
154
+ )
155
+ console.log(result.stdout)
156
+ ```
157
+
158
+ Client-wide `args` are not inserted into a shell string because doing so would
159
+ require rewriting arbitrary shell syntax. Put global CLI arguments directly in
160
+ the command when using `shell()`.
161
+
105
162
  ### Typed JSON
106
163
 
107
164
  Set `output: 'json'` for commands that support `--json`. `exec<T>()` adds the
@@ -143,6 +200,17 @@ const result = await browser.exec('network', { args: ['har', 'stop'] })
143
200
  await writeFile('capture.har', result.file.bytes)
144
201
  ```
145
202
 
203
+ When a command already contains an explicit remote output path, read it back
204
+ without changing the command:
205
+
206
+ ```ts
207
+ await browser.shell(
208
+ 'agent-browser screenshot /tmp/verification.png',
209
+ )
210
+ const file = await browser.readFile('/tmp/verification.png', 'image/png')
211
+ await writeFile('verification.png', file.bytes)
212
+ ```
213
+
146
214
  ### Convenience methods
147
215
 
148
216
  - `browser.snapshot(url?)` optionally opens a page, then returns its interactive
@@ -153,6 +221,41 @@ await writeFile('capture.har', result.file.bytes)
153
221
 
154
222
  All methods use the same disposable browser session until `close()` is called.
155
223
 
224
+ ### Stable browser ids
225
+
226
+ Use `AgentBrowser.session()` when browser identity must survive process
227
+ boundaries. It returns a lazy handle: constructing it or starting keepalive
228
+ does not create a Sandbox. The first browser command finds or creates a runtime
229
+ derived from the caller-defined id, and another process using the same id finds
230
+ that runtime again.
231
+
232
+ ```ts
233
+ const browser = AgentBrowser.session({ id: `environment:${chatId}` })
234
+ const stopKeepalive = browser.keepalive()
235
+ try {
236
+ await browser.exec('open', { args: ['https://example.com'] })
237
+ } finally {
238
+ stopKeepalive()
239
+ }
240
+ ```
241
+
242
+ Ids are project-scoped. Include the deployment environment or another namespace
243
+ when the same application uses one Vercel project for multiple environments.
244
+ The underlying Sandbox name is private and derived from a hash of the id.
245
+
246
+ If an expired Sandbox resumes, its Chromium process starts fresh. Subscribe to
247
+ `reset` before running commands when the caller needs to surface that page,
248
+ cookies, refs, tabs, and console history were lost:
249
+
250
+ ```ts
251
+ browser.on('reset', ({ reason }) => {
252
+ console.log(`Browser runtime reset: ${reason}`)
253
+ })
254
+ ```
255
+
256
+ `browser.destroy()` permanently removes the named runtime. Stopping keepalive
257
+ only allows its normal idle timeout to resume.
258
+
156
259
  ### Keepalive
157
260
 
158
261
  Keep the Sandbox alive across idle gaps during long-running work. Always stop
package/dist/index.d.ts CHANGED
@@ -36,6 +36,18 @@ type BrowserRunResult = {
36
36
  results: BrowserCommandResult[];
37
37
  ok: boolean;
38
38
  };
39
+ /** Result from a verbatim shell command running in the browser environment. */
40
+ type BrowserShellResult = {
41
+ ok: boolean;
42
+ exitCode: number | null;
43
+ stdout: string;
44
+ stderr: string;
45
+ };
46
+ type ShellOptions = {
47
+ /** Named agent-browser CLI session available to commands through the environment. */
48
+ session?: string;
49
+ timeoutMs?: number;
50
+ };
39
51
  type RunOptions = {
40
52
  /** Named session to reuse browser state across calls. Default: ephemeral. */
41
53
  session?: string;
@@ -72,6 +84,8 @@ type BrowserClientOptions = {
72
84
  args?: string[];
73
85
  /** Proxy configuration fixed for this client's lifetime. */
74
86
  proxy?: ProxyOptions;
87
+ /** Run `agent-browser close` before closing the runner. Default: true. */
88
+ closeSession?: boolean;
75
89
  /** Whether close() also tears down the command runner. Default: true. */
76
90
  ownsRunner?: boolean;
77
91
  };
@@ -83,6 +97,12 @@ type KeepaliveOptions = {
83
97
  /** Optional observer for best-effort heartbeat failures. */
84
98
  onError?: (error: unknown) => void;
85
99
  };
100
+ type BrowserRuntimeResetEvent = {
101
+ /** Stable logical browser id supplied by the caller. */
102
+ id: string;
103
+ /** Why the backing runtime no longer has its previous in-memory state. */
104
+ reason: 'resumed';
105
+ };
86
106
  /**
87
107
  * Minimal command executor the browser client needs. Implemented by the
88
108
  * Vercel Sandbox adapter; anything with the same shape can back a
@@ -113,6 +133,8 @@ interface AgentBrowser$1 {
113
133
  readonly session: string;
114
134
  /** Run a batch of agent-browser arg-arrays sequentially. */
115
135
  run(commands: string[][], opts?: RunOptions): Promise<BrowserRunResult>;
136
+ /** Run a shell command verbatim, preserving quoting and control operators. */
137
+ shell(command: string, opts?: ShellOptions): Promise<BrowserShellResult>;
116
138
  /** Run one command and unwrap its typed --json data payload. */
117
139
  exec<T>(command: string, opts: JsonExecOptions): Promise<BrowserCommandResult<T>>;
118
140
  /** Run one agent-browser command with text output. */
@@ -126,6 +148,8 @@ interface AgentBrowser$1 {
126
148
  file: BrowserFile;
127
149
  result: BrowserCommandResult;
128
150
  }>;
151
+ /** Read an explicitly named artifact from the browser environment. */
152
+ readFile(path: string, contentType?: string): Promise<BrowserFile>;
129
153
  /** Optionally open a URL, then return the interactive accessibility snapshot. */
130
154
  snapshot(urlOrOptions?: string | RunOptions, opts?: RunOptions): Promise<BrowserRunResult>;
131
155
  /** Optionally open a URL, then return a PNG screenshot. */
@@ -141,8 +165,26 @@ interface AgentBrowser$1 {
141
165
  /** Close the browser session (and the sandbox when it owns it). */
142
166
  close(): Promise<void>;
143
167
  }
168
+ /** A lazily acquired browser whose logical identity survives runtime expiry. */
169
+ interface AgentBrowserSession extends AgentBrowser$1 {
170
+ /** Stable caller-defined identity. The backing runtime may be replaced. */
171
+ readonly id: string;
172
+ /** Observe when an expired runtime resumes with fresh in-memory browser state. */
173
+ on(event: 'reset', listener: (event: BrowserRuntimeResetEvent) => void): () => void;
174
+ /** Permanently remove the runtime currently associated with this id. */
175
+ destroy(): Promise<void>;
176
+ }
177
+ /** Explicit Sandbox scope; resolve a fresh token while keeping team/project fixed. */
178
+ type SandboxCredentials = {
179
+ token: string;
180
+ teamId: string;
181
+ projectId: string;
182
+ };
183
+ type ResolveSandboxCredentials = () => Promise<SandboxCredentials | undefined>;
144
184
 
145
185
  type CreateAgentBrowserOptions = {
186
+ /** Fresh credentials for a fixed Sandbox scope; omit for deployment auth. */
187
+ resolveSandboxCredentials?: ResolveSandboxCredentials;
146
188
  /** Named CLI session; state (page, cookies, refs) persists across calls. */
147
189
  session?: string;
148
190
  /**
@@ -161,6 +203,10 @@ type CreateAgentBrowserOptions = {
161
203
  /** Proxy configuration fixed for this browser's lifetime. */
162
204
  proxy?: ProxyOptions;
163
205
  };
206
+ type CreateAgentBrowserSessionOptions = CreateAgentBrowserOptions & {
207
+ /** Stable logical identity used to find this browser across processes. */
208
+ id: string;
209
+ };
164
210
  /**
165
211
  * Create an agent-browser client running inside a fresh Vercel Sandbox.
166
212
  *
@@ -184,6 +230,15 @@ type CreateAgentBrowserOptions = {
184
230
  * ```
185
231
  */
186
232
  declare function createAgentBrowser(opts?: CreateAgentBrowserOptions): Promise<AgentBrowser$1>;
233
+ /**
234
+ * Create a lazy browser handle with a stable logical id.
235
+ *
236
+ * The first operation finds or creates a named Vercel Sandbox. Later handles
237
+ * created with the same id attach to that Sandbox, including from another
238
+ * process. If an expired Sandbox resumes, listeners are told that its
239
+ * in-memory page, refs, cookies, and console history were reset.
240
+ */
241
+ declare function createAgentBrowserSession(opts: CreateAgentBrowserSessionOptions): AgentBrowserSession;
187
242
 
188
243
  /**
189
244
  * Create a session-oriented browser client over any CommandRunner.
@@ -198,8 +253,9 @@ declare function createBrowserClient(runner: CommandRunner, opts?: BrowserClient
198
253
  /** Create AgentBrowser clients backed by a fresh Vercel Sandbox. */
199
254
  declare const AgentBrowser: {
200
255
  create: typeof createAgentBrowser;
256
+ session: typeof createAgentBrowserSession;
201
257
  };
202
258
  type AgentBrowser = AgentBrowser$1;
203
259
 
204
- export { AgentBrowser, createAgentBrowser, createBrowserClient };
205
- export type { BrowserClientOptions, BrowserCommandResult, BrowserRunResult, CommandRunner, CreateAgentBrowserOptions, ExecOptions, JsonExecOptions, KeepaliveOptions, ProxyOptions, RunOptions };
260
+ export { AgentBrowser, createAgentBrowser, createAgentBrowserSession, createBrowserClient };
261
+ export type { AgentBrowserSession, BrowserClientOptions, BrowserCommandResult, BrowserFile, BrowserRunResult, BrowserRuntimeResetEvent, BrowserShellResult, CommandRunner, CreateAgentBrowserOptions, CreateAgentBrowserSessionOptions, ExecOptions, JsonExecOptions, KeepaliveOptions, ProxyOptions, ResolveSandboxCredentials, RunOptions, SandboxCredentials, ShellOptions };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { randomUUID, createHash } from 'node:crypto';
2
2
  import { Sandbox } from '@vercel/sandbox';
3
3
 
4
4
  const FLAGS_WITH_VALUE = new Set([
@@ -150,11 +150,28 @@ function outputSpec(args) {
150
150
  const clientEnv = proxyEnvironment(opts.proxy);
151
151
  const redactProxy = proxyOutputRedactor(opts.proxy);
152
152
  const ownsRunner = opts.ownsRunner ?? true;
153
+ const closeSession = opts.closeSession ?? true;
153
154
  const recordings = new Map();
154
155
  let closed = false;
155
156
  function assertOpen() {
156
157
  if (closed) throw new Error('AgentBrowser is closed');
157
158
  }
159
+ async function runWithRedaction(cmd, args, options) {
160
+ try {
161
+ const run = await runner.run(cmd, args, options);
162
+ return {
163
+ ...run,
164
+ stdout: redactProxy(run.stdout),
165
+ stderr: redactProxy(run.stderr)
166
+ };
167
+ } catch (error) {
168
+ if (error instanceof Error) {
169
+ error.message = redactProxy(error.message);
170
+ if (error.stack) error.stack = redactProxy(error.stack);
171
+ }
172
+ throw error;
173
+ }
174
+ }
158
175
  async function execCommand(args, timeoutMs, useSession = session) {
159
176
  const [name, ...rest] = args;
160
177
  // File-producing commands get a temporary remote output path when the
@@ -188,25 +205,16 @@ function outputSpec(args) {
188
205
  remotePath = recording?.path;
189
206
  fileSpec = recording?.spec;
190
207
  }
191
- let run;
192
- try {
193
- run = await runner.run('agent-browser', finalArgs, {
194
- timeoutMs,
195
- env: clientEnv
196
- });
197
- } catch (error) {
198
- if (error instanceof Error) {
199
- error.message = redactProxy(error.message);
200
- if (error.stack) error.stack = redactProxy(error.stack);
201
- }
202
- throw error;
203
- }
208
+ const run = await runWithRedaction('agent-browser', finalArgs, {
209
+ timeoutMs,
210
+ env: clientEnv
211
+ });
204
212
  const result = {
205
213
  args,
206
214
  ok: run.exitCode === 0,
207
215
  exitCode: run.exitCode,
208
- stdout: redactProxy(run.stdout),
209
- stderr: redactProxy(run.stderr)
216
+ stdout: run.stdout,
217
+ stderr: run.stderr
210
218
  };
211
219
  if (result.ok && startsRecording && remotePath && fileSpec) {
212
220
  recordings.set(useSession, {
@@ -297,6 +305,30 @@ function outputSpec(args) {
297
305
  ok: results.every((r)=>r.ok)
298
306
  };
299
307
  },
308
+ async shell (command, shellOpts = {}) {
309
+ assertOpen();
310
+ if (!command.trim()) throw new Error('shell command must not be empty');
311
+ const useSession = shellOpts.session ?? session;
312
+ if (!SESSION_NAME.test(useSession)) {
313
+ throw new Error(`session must match [A-Za-z0-9_.-]{1,64}, got "${useSession}"`);
314
+ }
315
+ const run = await runWithRedaction('sh', [
316
+ '-c',
317
+ command
318
+ ], {
319
+ timeoutMs: shellOpts.timeoutMs,
320
+ env: {
321
+ ...clientEnv,
322
+ AGENT_BROWSER_SESSION: useSession
323
+ }
324
+ });
325
+ return {
326
+ ok: run.exitCode === 0,
327
+ exitCode: run.exitCode,
328
+ stdout: run.stdout,
329
+ stderr: run.stderr
330
+ };
331
+ },
300
332
  exec,
301
333
  async upload (selector, files, execOpts = {}) {
302
334
  assertOpen();
@@ -341,6 +373,15 @@ function outputSpec(args) {
341
373
  result
342
374
  };
343
375
  },
376
+ async readFile (path, contentType = 'application/octet-stream') {
377
+ assertOpen();
378
+ if (!path) throw new Error('file path must not be empty');
379
+ return {
380
+ path,
381
+ bytes: await runner.readFile(path),
382
+ contentType
383
+ };
384
+ },
344
385
  async snapshot (urlOrOptions, opts = {}) {
345
386
  const url = typeof urlOrOptions === 'string' ? urlOrOptions : undefined;
346
387
  const runOpts = typeof urlOrOptions === 'string' ? opts : urlOrOptions ?? {};
@@ -392,12 +433,14 @@ function outputSpec(args) {
392
433
  if (closed) return;
393
434
  closed = true;
394
435
  // Close the CLI session's browser first so the sandbox can stop cleanly.
395
- await runner.run('agent-browser', withSession(session, clientArgs, [
396
- 'close'
397
- ]), {
398
- timeoutMs: 15_000,
399
- env: clientEnv
400
- }).catch(()=>{});
436
+ if (closeSession) {
437
+ await runner.run('agent-browser', withSession(session, clientArgs, [
438
+ 'close'
439
+ ]), {
440
+ timeoutMs: 15_000,
441
+ env: clientEnv
442
+ }).catch(()=>{});
443
+ }
401
444
  if (ownsRunner) await runner.close();
402
445
  }
403
446
  };
@@ -410,15 +453,60 @@ const BASE_ENV = {
410
453
  AGENT_BROWSER_DEFAULT_TIMEOUT: '25000'
411
454
  };
412
455
  /** CommandRunner backed by one Vercel Sandbox. */ class SandboxRunner {
413
- constructor(sandbox, env, timeoutMs = DEFAULT_TIMEOUT_MS){
456
+ constructor(sandbox, env, timeoutMs = DEFAULT_TIMEOUT_MS, resolveSandboxCredentials){
414
457
  this.keepaliveStops = new Set();
415
458
  this.sandbox = sandbox;
416
459
  this.env = env;
417
460
  this.timeoutMs = timeoutMs;
461
+ this.resolveSandboxCredentials = resolveSandboxCredentials;
462
+ }
463
+ async acquire() {
464
+ if (!this.sandbox) throw new Error('browser sandbox is unavailable');
465
+ return this.sandbox;
466
+ }
467
+ async observe() {
468
+ return this.observeWithCredentials(await this.resolveSandboxCredentials?.());
469
+ }
470
+ async observeWithCredentials(credentials) {
471
+ if (!this.sandbox) return null;
472
+ const sandbox = await Sandbox.get({
473
+ ...credentials,
474
+ name: this.sandbox.name,
475
+ resume: false
476
+ });
477
+ this.sandbox = sandbox;
478
+ return sandbox;
479
+ }
480
+ async destroyRuntime() {
481
+ if (this.resolveSandboxCredentials) {
482
+ const credentials = await this.resolveSandboxCredentials();
483
+ if (credentials) {
484
+ const sandbox = await this.observeWithCredentials(credentials);
485
+ await sandbox?.stop();
486
+ return;
487
+ }
488
+ }
489
+ await this.sandbox?.stop().catch(()=>{});
418
490
  }
419
491
  async run(cmd, args, opts = {}) {
420
492
  const timeoutMs = opts.timeoutMs ?? 60_000;
421
- const command = await this.sandbox.runCommand({
493
+ // A command is activity: restore the configured idle window even when no
494
+ // explicit heartbeat is running. Renewal is best-effort so an observability
495
+ // or platform hiccup does not block useful browser work.
496
+ const hadSandbox = this.sandbox !== undefined;
497
+ let sandbox = await this.acquire();
498
+ if (hadSandbox) {
499
+ const credentials = await this.resolveSandboxCredentials?.();
500
+ if (credentials) {
501
+ const observed = await this.observeWithCredentials(credentials);
502
+ if (!observed) throw new Error('browser sandbox is unavailable');
503
+ sandbox = observed;
504
+ } else {
505
+ sandbox = await this.observeWithCredentials(undefined).catch(()=>null) ?? sandbox;
506
+ }
507
+ }
508
+ await this.restoreLifetime(sandbox, this.timeoutMs).catch(()=>{});
509
+ const command = await sandbox.runCommand({
422
510
  cmd,
423
511
  args,
424
512
  env: {
@@ -428,8 +516,12 @@ const BASE_ENV = {
428
516
  detached: true
429
517
  });
430
518
  let timer;
519
+ let timedOut = false;
431
520
  const timeout = new Promise((_, reject)=>{
432
- timer = setTimeout(()=>reject(new Error(`command timed out after ${timeoutMs}ms`)), timeoutMs);
521
+ timer = setTimeout(()=>{
522
+ timedOut = true;
523
+ reject(new Error(`command timed out after ${timeoutMs}ms`));
524
+ }, timeoutMs);
433
525
  if (typeof timer.unref === 'function') timer.unref();
434
526
  });
435
527
  try {
@@ -449,11 +541,38 @@ const BASE_ENV = {
449
541
  };
450
542
  } catch (error) {
451
543
  await command.kill().catch(()=>{});
544
+ if (timedOut) await this.reclaimBrowser(sandbox);
452
545
  throw error;
453
546
  } finally{
454
547
  if (timer) clearTimeout(timer);
455
548
  }
456
549
  }
550
+ /** A timed-out CLI call may leave Chromium wedged; force a clean relaunch. */ async reclaimBrowser(sandbox) {
551
+ try {
552
+ const command = await sandbox.runCommand({
553
+ cmd: 'sh',
554
+ args: [
555
+ '-c',
556
+ 'pkill -9 chromium || true; pkill -9 chrome || true'
557
+ ],
558
+ detached: true
559
+ });
560
+ let timer;
561
+ const timeout = new Promise((resolve)=>{
562
+ // Cleanup must not turn the original command timeout into an unbounded wait.
563
+ timer = setTimeout(resolve, 30_000);
564
+ if (typeof timer.unref === 'function') timer.unref();
565
+ });
566
+ try {
567
+ await Promise.race([
568
+ command.wait(),
569
+ timeout
570
+ ]);
571
+ } finally{
572
+ if (timer) clearTimeout(timer);
573
+ }
574
+ } catch {}
575
+ }
457
576
  async readFile(path) {
458
577
  const { stdout, exitCode, stderr } = await this.run('base64', [
459
578
  '-w0',
@@ -467,27 +586,37 @@ const BASE_ENV = {
467
586
  return Buffer.from(stdout.trim(), 'base64');
468
587
  }
469
588
  async writeFile(path, bytes) {
470
- await this.sandbox.writeFiles([
589
+ const hadSandbox = this.sandbox !== undefined;
590
+ let sandbox = await this.acquire();
591
+ if (hadSandbox) {
592
+ const credentials = await this.resolveSandboxCredentials?.();
593
+ if (credentials) {
594
+ const observed = await this.observeWithCredentials(credentials);
595
+ if (!observed) throw new Error('browser sandbox is unavailable');
596
+ sandbox = observed;
597
+ }
598
+ }
599
+ await sandbox.writeFiles([
471
600
  {
472
601
  path,
473
602
  content: bytes
474
603
  }
475
604
  ]);
476
605
  }
477
- /** Restore a sliding idle window without cumulatively adding that window. */ async renewLifetime(timeoutMs) {
478
- // Poll without resuming: a late heartbeat must not resurrect an already
479
- // expired Sandbox whose in-memory browser state is gone.
480
- const sandbox = await Sandbox.get({
481
- name: this.sandbox.name,
482
- resume: false
483
- });
606
+ /** Restore a sliding idle window without cumulatively adding that window. */ async restoreLifetime(sandbox, timeoutMs) {
484
607
  if (sandbox.status !== 'running') return;
485
- this.sandbox = sandbox;
486
608
  const targetExpiresAt = Date.now() + timeoutMs;
487
609
  const currentExpiresAt = sandbox.expiresAt?.getTime();
488
610
  const extensionMs = currentExpiresAt === undefined ? timeoutMs : Math.ceil(targetExpiresAt - currentExpiresAt);
489
611
  if (extensionMs > 0) await sandbox.extendTimeout(extensionMs);
490
612
  }
613
+ async renewLifetime(timeoutMs) {
614
+ // Poll without resuming: a late heartbeat must not resurrect an already
615
+ // expired Sandbox whose in-memory browser state is gone.
616
+ const sandbox = await this.observe();
617
+ if (!sandbox) return;
618
+ await this.restoreLifetime(sandbox, timeoutMs);
619
+ }
491
620
  keepalive(opts = {}) {
492
621
  const timeoutMs = opts.timeoutMs ?? this.timeoutMs;
493
622
  const intervalMs = opts.intervalMs ?? Math.min(5 * 60 * 1000, Math.floor(timeoutMs / 2));
@@ -526,9 +655,103 @@ const BASE_ENV = {
526
655
  for (const stop of [
527
656
  ...this.keepaliveStops
528
657
  ])stop();
529
- await this.sandbox.stop().catch(()=>{});
658
+ await this.destroyRuntime();
659
+ }
660
+ }
661
+ function stableNames(id) {
662
+ const hash = createHash('sha256').update(id).digest('hex');
663
+ return {
664
+ sandbox: `remote-browser-${hash.slice(0, 32)}`,
665
+ session: `session-${hash.slice(0, 32)}`
666
+ };
667
+ }
668
+ function isMissingSandbox(error) {
669
+ if (!(error instanceof Error)) return false;
670
+ const code = error.json?.error?.code;
671
+ return code === 'not_found' || error.message.includes('Status code 404');
672
+ }
673
+ class NamedSandboxRunner extends SandboxRunner {
674
+ constructor(opts){
675
+ const names = stableNames(opts.id);
676
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
677
+ super(undefined, {
678
+ ...BASE_ENV,
679
+ ...opts.env
680
+ }, timeoutMs, opts.resolveSandboxCredentials), this.resetListeners = new Set(), this.onResume = async ()=>{
681
+ const event = {
682
+ id: this.id,
683
+ reason: 'resumed'
684
+ };
685
+ for (const listener of this.resetListeners){
686
+ try {
687
+ listener(event);
688
+ } catch {}
689
+ }
690
+ };
691
+ this.id = opts.id;
692
+ this.name = names.sandbox;
693
+ this.session = names.session;
694
+ this.image = opts.image ?? process.env.REMOTE_AGENT_BROWSER_IMAGE ?? DEFAULT_BROWSER_IMAGE;
695
+ this.vcpus = opts.vcpus ?? 2;
696
+ }
697
+ on(event, listener) {
698
+ if (event !== 'reset') throw new Error(`unsupported browser event: ${event}`);
699
+ this.resetListeners.add(listener);
700
+ return ()=>this.resetListeners.delete(listener);
701
+ }
702
+ async acquire() {
703
+ if (this.sandbox) return this.sandbox;
704
+ this.sandbox = await Sandbox.getOrCreate({
705
+ ...await this.resolveSandboxCredentials?.(),
706
+ name: this.name,
707
+ image: this.image,
708
+ resources: {
709
+ vcpus: this.vcpus
710
+ },
711
+ timeout: this.timeoutMs,
712
+ ports: [],
713
+ env: this.env,
714
+ persistent: false,
715
+ onResume: this.onResume
716
+ });
717
+ return this.sandbox;
718
+ }
719
+ async observeWithCredentials(credentials) {
720
+ try {
721
+ const sandbox = await Sandbox.get({
722
+ ...credentials,
723
+ name: this.name,
724
+ resume: false,
725
+ onResume: this.onResume
726
+ });
727
+ this.sandbox = sandbox;
728
+ return sandbox;
729
+ } catch (error) {
730
+ if (isMissingSandbox(error)) return null;
731
+ throw error;
732
+ }
733
+ }
734
+ async destroyRuntime() {
735
+ try {
736
+ const credentials = await this.resolveSandboxCredentials?.();
737
+ let sandbox = this.sandbox;
738
+ if (credentials || !sandbox) {
739
+ sandbox = await Sandbox.get({
740
+ ...credentials,
741
+ name: this.name,
742
+ resume: false
743
+ });
744
+ }
745
+ await sandbox.delete();
746
+ this.sandbox = undefined;
747
+ } catch (error) {
748
+ if (!isMissingSandbox(error)) throw error;
749
+ }
530
750
  }
531
751
  }
752
+ /** Create a lazy runner backed by a stable, project-scoped browser id. */ function createNamedBrowserSandboxRunner(opts) {
753
+ return new NamedSandboxRunner(opts);
754
+ }
532
755
  /**
533
756
  * Start an ephemeral Vercel Sandbox from the pre-built browser image.
534
757
  *
@@ -543,6 +766,7 @@ const BASE_ENV = {
543
766
  };
544
767
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
545
768
  const sandbox = await Sandbox.create({
769
+ ...await opts.resolveSandboxCredentials?.(),
546
770
  image,
547
771
  resources: {
548
772
  vcpus: opts.vcpus ?? 2
@@ -552,7 +776,7 @@ const BASE_ENV = {
552
776
  env,
553
777
  persistent: false
554
778
  });
555
- return new SandboxRunner(sandbox, env, timeoutMs);
779
+ return new SandboxRunner(sandbox, env, timeoutMs, opts.resolveSandboxCredentials);
556
780
  }
557
781
 
558
782
  /**
@@ -581,7 +805,8 @@ const BASE_ENV = {
581
805
  image: opts.image,
582
806
  timeoutMs: opts.timeoutMs,
583
807
  vcpus: opts.vcpus,
584
- env: opts.env
808
+ env: opts.env,
809
+ resolveSandboxCredentials: opts.resolveSandboxCredentials
585
810
  });
586
811
  return createBrowserClient(runner, {
587
812
  session: opts.session,
@@ -590,11 +815,47 @@ const BASE_ENV = {
590
815
  ownsRunner: true
591
816
  });
592
817
  }
818
+ /**
819
+ * Create a lazy browser handle with a stable logical id.
820
+ *
821
+ * The first operation finds or creates a named Vercel Sandbox. Later handles
822
+ * created with the same id attach to that Sandbox, including from another
823
+ * process. If an expired Sandbox resumes, listeners are told that its
824
+ * in-memory page, refs, cookies, and console history were reset.
825
+ */ function createAgentBrowserSession(opts) {
826
+ if (!opts.id.trim()) throw new Error('browser id must not be empty');
827
+ const runner = createNamedBrowserSandboxRunner({
828
+ id: opts.id,
829
+ image: opts.image,
830
+ timeoutMs: opts.timeoutMs,
831
+ vcpus: opts.vcpus,
832
+ env: opts.env,
833
+ resolveSandboxCredentials: opts.resolveSandboxCredentials
834
+ });
835
+ const browser = createBrowserClient(runner, {
836
+ session: opts.session ?? runner.session,
837
+ args: opts.args,
838
+ proxy: opts.proxy,
839
+ closeSession: false,
840
+ ownsRunner: true
841
+ });
842
+ return {
843
+ ...browser,
844
+ id: opts.id,
845
+ on (event, listener) {
846
+ return runner.on(event, listener);
847
+ },
848
+ destroy () {
849
+ return browser.close();
850
+ }
851
+ };
852
+ }
593
853
 
594
854
  // Public library surface: create an agent browser in a Vercel Sandbox and
595
855
  // drive it with the agent-browser CLI.
596
856
  /** Create AgentBrowser clients backed by a fresh Vercel Sandbox. */ const AgentBrowser = {
597
- create: createAgentBrowser
857
+ create: createAgentBrowser,
858
+ session: createAgentBrowserSession
598
859
  };
599
860
 
600
- export { AgentBrowser, createAgentBrowser, createBrowserClient };
861
+ export { AgentBrowser, createAgentBrowser, createAgentBrowserSession, createBrowserClient };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-agent-browser",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Run agent-browser in a Vercel Sandbox created from a browser-ready container image",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -16,6 +16,7 @@
16
16
  },
17
17
  "scripts": {
18
18
  "build": "bunchee --runtime node",
19
+ "benchmark": "node --run build && node benchmarks/chromium.mjs",
19
20
  "prepublishOnly": "node --run build",
20
21
  "typecheck": "tsc -p tsconfig.json --noEmit",
21
22
  "test": "node --run build && node --test tests/*.test.mjs",