remote-agent-browser 1.0.0 → 1.2.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
@@ -1,8 +1,7 @@
1
1
  # remote-agent-browser
2
2
 
3
- Run [agent-browser](https://github.com/vercel-labs/agent-browser) in an isolated
4
- [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox). Chromium and the CLI
5
- come preinstalled, so there is no browser or Docker setup at runtime.
3
+ Run [agent-browser](https://github.com/vercel-labs/agent-browser) in the cloud
4
+ with an isolated [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox).
6
5
 
7
6
  ## Install
8
7
 
@@ -22,6 +21,10 @@ references. `close()` closes Chromium and stops the Sandbox.
22
21
  PNG and save it locally.
23
22
  - [Agent Bash tool integration](./examples/agent-bash-tool/README.md) — let an
24
23
  agent use normal `agent-browser <command>` invocations through its Bash tool.
24
+ - [Bypass BotID for trusted automation](./examples/botid-bypass/README.md) —
25
+ pass a Vercel automation bypass token as an origin-scoped header.
26
+ - [Route traffic through a proxy](./examples/proxy/README.md) — configure an
27
+ authenticated proxy and verify the browser's egress address.
25
28
 
26
29
  ## Authentication
27
30
 
@@ -45,7 +48,7 @@ Starts a fresh browser in a disposable Vercel Sandbox. Always call
45
48
 
46
49
  Pass agent-browser global options with `args`. They are placed before
47
50
  every command, which is required for launch settings such as color scheme,
48
- profiles, proxies, and init scripts:
51
+ profiles, and init scripts:
49
52
 
50
53
  ```ts
51
54
  const browser = await AgentBrowser.create({
@@ -53,6 +56,28 @@ const browser = await AgentBrowser.create({
53
56
  })
54
57
  ```
55
58
 
59
+ ### Proxy
60
+
61
+ Set a proxy for the browser client's lifetime with `proxy`. Use the object form
62
+ to configure hosts that should connect directly:
63
+
64
+ ```ts
65
+ const browser = await AgentBrowser.create({
66
+ proxy: {
67
+ url: 'http://user:password@proxy.example.com:8080',
68
+ bypass: ['localhost', '*.internal.example.com'],
69
+ },
70
+ })
71
+ ```
72
+
73
+ For a proxy without bypass rules, pass its URL directly:
74
+
75
+ ```ts
76
+ const browser = await AgentBrowser.create({
77
+ proxy: 'http://proxy.example.com:8080',
78
+ })
79
+ ```
80
+
56
81
  ### `browser.run(commands)`
57
82
 
58
83
  Run several agent-browser commands in the same session:
@@ -77,6 +102,25 @@ await browser.exec('find', {
77
102
  })
78
103
  ```
79
104
 
105
+ ### `browser.shell(command, options?)`
106
+
107
+ Use this when forwarding a Bash-tool command or composing `agent-browser` with
108
+ normal shell utilities. The command runs verbatim, so quoting, pipes,
109
+ redirection, and control operators keep their shell semantics. Prefer `exec()`
110
+ or `run()` for browser commands that do not need a shell. Every `agent-browser`
111
+ invocation inherits the client's CLI session through `AGENT_BROWSER_SESSION`.
112
+
113
+ ```ts
114
+ const result = await browser.shell(
115
+ 'agent-browser read "https://example.com" | grep -io "example" | wc -l',
116
+ )
117
+ console.log(result.stdout)
118
+ ```
119
+
120
+ Client-wide `args` are not inserted into a shell string because doing so would
121
+ require rewriting arbitrary shell syntax. Put global CLI arguments directly in
122
+ the command when using `shell()`.
123
+
80
124
  ### Typed JSON
81
125
 
82
126
  Set `output: 'json'` for commands that support `--json`. `exec<T>()` adds the
@@ -118,6 +162,17 @@ const result = await browser.exec('network', { args: ['har', 'stop'] })
118
162
  await writeFile('capture.har', result.file.bytes)
119
163
  ```
120
164
 
165
+ When a command already contains an explicit remote output path, read it back
166
+ without changing the command:
167
+
168
+ ```ts
169
+ await browser.shell(
170
+ 'agent-browser screenshot /tmp/verification.png',
171
+ )
172
+ const file = await browser.readFile('/tmp/verification.png', 'image/png')
173
+ await writeFile('verification.png', file.bytes)
174
+ ```
175
+
121
176
  ### Convenience methods
122
177
 
123
178
  - `browser.snapshot(url?)` optionally opens a page, then returns its interactive
@@ -128,4 +183,59 @@ await writeFile('capture.har', result.file.bytes)
128
183
 
129
184
  All methods use the same disposable browser session until `close()` is called.
130
185
 
186
+ ### Stable browser ids
187
+
188
+ Use `AgentBrowser.session()` when browser identity must survive process
189
+ boundaries. It returns a lazy handle: constructing it or starting keepalive
190
+ does not create a Sandbox. The first browser command finds or creates a runtime
191
+ derived from the caller-defined id, and another process using the same id finds
192
+ that runtime again.
193
+
194
+ ```ts
195
+ const browser = AgentBrowser.session({ id: `environment:${chatId}` })
196
+ const stopKeepalive = browser.keepalive()
197
+ try {
198
+ await browser.exec('open', { args: ['https://example.com'] })
199
+ } finally {
200
+ stopKeepalive()
201
+ }
202
+ ```
203
+
204
+ Ids are project-scoped. Include the deployment environment or another namespace
205
+ when the same application uses one Vercel project for multiple environments.
206
+ The underlying Sandbox name is private and derived from a hash of the id.
207
+
208
+ If an expired Sandbox resumes, its Chromium process starts fresh. Subscribe to
209
+ `reset` before running commands when the caller needs to surface that page,
210
+ cookies, refs, tabs, and console history were lost:
211
+
212
+ ```ts
213
+ browser.on('reset', ({ reason }) => {
214
+ console.log(`Browser runtime reset: ${reason}`)
215
+ })
216
+ ```
217
+
218
+ `browser.destroy()` permanently removes the named runtime. Stopping keepalive
219
+ only allows its normal idle timeout to resume.
220
+
221
+ ### Keepalive
222
+
223
+ Keep the Sandbox alive across idle gaps during long-running work. Always stop
224
+ the heartbeat in a `finally` block; after it stops, the Sandbox expires at its
225
+ normal timeout unless it is closed earlier:
226
+
227
+ ```ts
228
+ const stopKeepalive = browser.keepalive()
229
+ try {
230
+ await runLongAgentTurn(browser)
231
+ } finally {
232
+ stopKeepalive()
233
+ }
234
+ ```
235
+
236
+ By default, each heartbeat restores the wall-clock timeout configured by
237
+ `AgentBrowser.create()` and runs halfway through that window, capped at five
238
+ minutes. Override either value with `timeoutMs` and `intervalMs`. Renewal is
239
+ best-effort; pass `onError` to observe failures.
240
+
131
241
  Container image and development details are in [docs.md](./docs.md).
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;
@@ -60,13 +72,37 @@ type ExecOptions = {
60
72
  type JsonExecOptions = Omit<ExecOptions, 'output'> & {
61
73
  output: 'json';
62
74
  };
75
+ type ProxyOptions = string | {
76
+ /** Proxy server URL, optionally including authentication. */
77
+ url: string;
78
+ /** Host patterns that should connect directly instead of using the proxy. */
79
+ bypass?: string | readonly string[];
80
+ };
63
81
  type BrowserClientOptions = {
64
82
  session?: string;
65
83
  /** agent-browser CLI arguments inserted before every command. */
66
84
  args?: string[];
85
+ /** Proxy configuration fixed for this client's lifetime. */
86
+ proxy?: ProxyOptions;
87
+ /** Run `agent-browser close` before closing the runner. Default: true. */
88
+ closeSession?: boolean;
67
89
  /** Whether close() also tears down the command runner. Default: true. */
68
90
  ownsRunner?: boolean;
69
91
  };
92
+ type KeepaliveOptions = {
93
+ /** Remaining sandbox lifetime to maintain. Defaults to its creation timeout. */
94
+ timeoutMs?: number;
95
+ /** Heartbeat cadence. Defaults to half the timeout, capped at 5 minutes. */
96
+ intervalMs?: number;
97
+ /** Optional observer for best-effort heartbeat failures. */
98
+ onError?: (error: unknown) => void;
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
+ };
70
106
  /**
71
107
  * Minimal command executor the browser client needs. Implemented by the
72
108
  * Vercel Sandbox adapter; anything with the same shape can back a
@@ -88,6 +124,8 @@ interface CommandRunner {
88
124
  writeFile(path: string, bytes: Buffer): Promise<void>;
89
125
  /** Tear the remote environment down. */
90
126
  close(): Promise<void>;
127
+ /** Keep the remote environment alive until the returned stop function runs. */
128
+ keepalive?(opts?: KeepaliveOptions): () => void;
91
129
  }
92
130
  /** Session-oriented remote browser client. */
93
131
  interface AgentBrowser$1 {
@@ -95,6 +133,8 @@ interface AgentBrowser$1 {
95
133
  readonly session: string;
96
134
  /** Run a batch of agent-browser arg-arrays sequentially. */
97
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>;
98
138
  /** Run one command and unwrap its typed --json data payload. */
99
139
  exec<T>(command: string, opts: JsonExecOptions): Promise<BrowserCommandResult<T>>;
100
140
  /** Run one agent-browser command with text output. */
@@ -108,6 +148,8 @@ interface AgentBrowser$1 {
108
148
  file: BrowserFile;
109
149
  result: BrowserCommandResult;
110
150
  }>;
151
+ /** Read an explicitly named artifact from the browser environment. */
152
+ readFile(path: string, contentType?: string): Promise<BrowserFile>;
111
153
  /** Optionally open a URL, then return the interactive accessibility snapshot. */
112
154
  snapshot(urlOrOptions?: string | RunOptions, opts?: RunOptions): Promise<BrowserRunResult>;
113
155
  /** Optionally open a URL, then return a PNG screenshot. */
@@ -115,9 +157,23 @@ interface AgentBrowser$1 {
115
157
  png: Buffer;
116
158
  result: BrowserRunResult;
117
159
  }>;
160
+ /**
161
+ * Keep the backing Sandbox alive across idle gaps. Call the returned stop
162
+ * function in a finally block so the Sandbox can expire when work finishes.
163
+ */
164
+ keepalive(opts?: KeepaliveOptions): () => void;
118
165
  /** Close the browser session (and the sandbox when it owns it). */
119
166
  close(): Promise<void>;
120
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
+ }
121
177
 
122
178
  type CreateAgentBrowserOptions = {
123
179
  /** Named CLI session; state (page, cookies, refs) persists across calls. */
@@ -135,6 +191,12 @@ type CreateAgentBrowserOptions = {
135
191
  env?: Record<string, string>;
136
192
  /** agent-browser CLI arguments inserted before every command. */
137
193
  args?: string[];
194
+ /** Proxy configuration fixed for this browser's lifetime. */
195
+ proxy?: ProxyOptions;
196
+ };
197
+ type CreateAgentBrowserSessionOptions = CreateAgentBrowserOptions & {
198
+ /** Stable logical identity used to find this browser across processes. */
199
+ id: string;
138
200
  };
139
201
  /**
140
202
  * Create an agent-browser client running inside a fresh Vercel Sandbox.
@@ -159,6 +221,15 @@ type CreateAgentBrowserOptions = {
159
221
  * ```
160
222
  */
161
223
  declare function createAgentBrowser(opts?: CreateAgentBrowserOptions): Promise<AgentBrowser$1>;
224
+ /**
225
+ * Create a lazy browser handle with a stable logical id.
226
+ *
227
+ * The first operation finds or creates a named Vercel Sandbox. Later handles
228
+ * created with the same id attach to that Sandbox, including from another
229
+ * process. If an expired Sandbox resumes, listeners are told that its
230
+ * in-memory page, refs, cookies, and console history were reset.
231
+ */
232
+ declare function createAgentBrowserSession(opts: CreateAgentBrowserSessionOptions): AgentBrowserSession;
162
233
 
163
234
  /**
164
235
  * Create a session-oriented browser client over any CommandRunner.
@@ -173,8 +244,9 @@ declare function createBrowserClient(runner: CommandRunner, opts?: BrowserClient
173
244
  /** Create AgentBrowser clients backed by a fresh Vercel Sandbox. */
174
245
  declare const AgentBrowser: {
175
246
  create: typeof createAgentBrowser;
247
+ session: typeof createAgentBrowserSession;
176
248
  };
177
249
  type AgentBrowser = AgentBrowser$1;
178
250
 
179
- export { AgentBrowser, createAgentBrowser, createBrowserClient };
180
- export type { BrowserClientOptions, BrowserCommandResult, BrowserRunResult, CommandRunner, CreateAgentBrowserOptions, ExecOptions, JsonExecOptions, RunOptions };
251
+ export { AgentBrowser, createAgentBrowser, createAgentBrowserSession, createBrowserClient };
252
+ export type { AgentBrowserSession, BrowserClientOptions, BrowserCommandResult, BrowserFile, BrowserRunResult, BrowserRuntimeResetEvent, BrowserShellResult, CommandRunner, CreateAgentBrowserOptions, CreateAgentBrowserSessionOptions, ExecOptions, JsonExecOptions, KeepaliveOptions, ProxyOptions, RunOptions, 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([
@@ -12,6 +12,39 @@ const FLAGS_WITH_VALUE = new Set([
12
12
  '--scale'
13
13
  ]);
14
14
  const SESSION_NAME = /^[\w.-]{1,64}$/;
15
+ const PROXY_FLAGS = [
16
+ '--proxy',
17
+ '--proxy-bypass'
18
+ ];
19
+ function proxyEnvironment(proxy) {
20
+ if (proxy === undefined) return undefined;
21
+ const url = typeof proxy === 'string' ? proxy : proxy.url;
22
+ const bypass = typeof proxy === 'string' ? undefined : proxy.bypass;
23
+ if (url.length === 0) {
24
+ throw new RangeError('proxy url must not be empty');
25
+ }
26
+ const env = {
27
+ AGENT_BROWSER_PROXY: url
28
+ };
29
+ if (bypass !== undefined) {
30
+ const serialized = typeof bypass === 'string' ? bypass : bypass.join(',');
31
+ if (serialized.length === 0) {
32
+ throw new RangeError('proxy bypass must not be empty');
33
+ }
34
+ env.AGENT_BROWSER_PROXY_BYPASS = serialized;
35
+ }
36
+ return env;
37
+ }
38
+ function hasProxyFlag(args) {
39
+ return args.some((arg)=>PROXY_FLAGS.some((flag)=>arg === flag || arg.startsWith(`${flag}=`)));
40
+ }
41
+ function proxyOutputRedactor(proxy) {
42
+ if (proxy === undefined) return (value)=>value;
43
+ const url = typeof proxy === 'string' ? proxy : proxy.url;
44
+ const redacted = url.replace(/^([a-z][a-z\d+.-]*:\/\/)([^/@]+)@/i, '$1***:***@');
45
+ if (redacted === url) return (value)=>value;
46
+ return (value)=>value.replaceAll(url, redacted);
47
+ }
15
48
  function safeFilename(name) {
16
49
  const filename = name.split(/[\\/]/).at(-1)?.replace(/[^\w.-]/g, '-');
17
50
  return filename || 'file';
@@ -111,12 +144,34 @@ function outputSpec(args) {
111
144
  const clientArgs = [
112
145
  ...opts.args ?? []
113
146
  ];
147
+ if (opts.proxy !== undefined && hasProxyFlag(clientArgs)) {
148
+ throw new Error('proxy cannot be combined with --proxy or --proxy-bypass args');
149
+ }
150
+ const clientEnv = proxyEnvironment(opts.proxy);
151
+ const redactProxy = proxyOutputRedactor(opts.proxy);
114
152
  const ownsRunner = opts.ownsRunner ?? true;
153
+ const closeSession = opts.closeSession ?? true;
115
154
  const recordings = new Map();
116
155
  let closed = false;
117
156
  function assertOpen() {
118
157
  if (closed) throw new Error('AgentBrowser is closed');
119
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
+ }
120
175
  async function execCommand(args, timeoutMs, useSession = session) {
121
176
  const [name, ...rest] = args;
122
177
  // File-producing commands get a temporary remote output path when the
@@ -150,8 +205,9 @@ function outputSpec(args) {
150
205
  remotePath = recording?.path;
151
206
  fileSpec = recording?.spec;
152
207
  }
153
- const run = await runner.run('agent-browser', finalArgs, {
154
- timeoutMs
208
+ const run = await runWithRedaction('agent-browser', finalArgs, {
209
+ timeoutMs,
210
+ env: clientEnv
155
211
  });
156
212
  const result = {
157
213
  args,
@@ -249,6 +305,30 @@ function outputSpec(args) {
249
305
  ok: results.every((r)=>r.ok)
250
306
  };
251
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
+ },
252
332
  exec,
253
333
  async upload (selector, files, execOpts = {}) {
254
334
  assertOpen();
@@ -293,6 +373,15 @@ function outputSpec(args) {
293
373
  result
294
374
  };
295
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
+ },
296
385
  async snapshot (urlOrOptions, opts = {}) {
297
386
  const url = typeof urlOrOptions === 'string' ? urlOrOptions : undefined;
298
387
  const runOpts = typeof urlOrOptions === 'string' ? opts : urlOrOptions ?? {};
@@ -333,15 +422,25 @@ function outputSpec(args) {
333
422
  result: run
334
423
  };
335
424
  },
425
+ keepalive (opts = {}) {
426
+ assertOpen();
427
+ if (!runner.keepalive) {
428
+ throw new Error('the command runner does not support keepalive');
429
+ }
430
+ return runner.keepalive(opts);
431
+ },
336
432
  async close () {
337
433
  if (closed) return;
338
434
  closed = true;
339
435
  // Close the CLI session's browser first so the sandbox can stop cleanly.
340
- await runner.run('agent-browser', withSession(session, clientArgs, [
341
- 'close'
342
- ]), {
343
- timeoutMs: 15_000
344
- }).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
+ }
345
444
  if (ownsRunner) await runner.close();
346
445
  }
347
446
  };
@@ -354,13 +453,40 @@ const BASE_ENV = {
354
453
  AGENT_BROWSER_DEFAULT_TIMEOUT: '25000'
355
454
  };
356
455
  /** CommandRunner backed by one Vercel Sandbox. */ class SandboxRunner {
357
- constructor(sandbox, env){
456
+ constructor(sandbox, env, timeoutMs = DEFAULT_TIMEOUT_MS){
457
+ this.keepaliveStops = new Set();
358
458
  this.sandbox = sandbox;
359
459
  this.env = env;
460
+ this.timeoutMs = timeoutMs;
461
+ }
462
+ async acquire() {
463
+ if (!this.sandbox) throw new Error('browser sandbox is unavailable');
464
+ return this.sandbox;
465
+ }
466
+ async observe() {
467
+ if (!this.sandbox) return null;
468
+ const sandbox = await Sandbox.get({
469
+ name: this.sandbox.name,
470
+ resume: false
471
+ });
472
+ this.sandbox = sandbox;
473
+ return sandbox;
474
+ }
475
+ async destroyRuntime() {
476
+ await this.sandbox?.stop().catch(()=>{});
360
477
  }
361
478
  async run(cmd, args, opts = {}) {
362
479
  const timeoutMs = opts.timeoutMs ?? 60_000;
363
- const command = await this.sandbox.runCommand({
480
+ // A command is activity: restore the configured idle window even when no
481
+ // explicit heartbeat is running. Renewal is best-effort so an observability
482
+ // or platform hiccup does not block useful browser work.
483
+ const hadSandbox = this.sandbox !== undefined;
484
+ let sandbox = await this.acquire();
485
+ if (hadSandbox) {
486
+ sandbox = await this.observe().catch(()=>null) ?? sandbox;
487
+ }
488
+ await this.restoreLifetime(sandbox, this.timeoutMs).catch(()=>{});
489
+ const command = await sandbox.runCommand({
364
490
  cmd,
365
491
  args,
366
492
  env: {
@@ -370,8 +496,12 @@ const BASE_ENV = {
370
496
  detached: true
371
497
  });
372
498
  let timer;
499
+ let timedOut = false;
373
500
  const timeout = new Promise((_, reject)=>{
374
- timer = setTimeout(()=>reject(new Error(`command timed out after ${timeoutMs}ms`)), timeoutMs);
501
+ timer = setTimeout(()=>{
502
+ timedOut = true;
503
+ reject(new Error(`command timed out after ${timeoutMs}ms`));
504
+ }, timeoutMs);
375
505
  if (typeof timer.unref === 'function') timer.unref();
376
506
  });
377
507
  try {
@@ -391,11 +521,38 @@ const BASE_ENV = {
391
521
  };
392
522
  } catch (error) {
393
523
  await command.kill().catch(()=>{});
524
+ if (timedOut) await this.reclaimBrowser(sandbox);
394
525
  throw error;
395
526
  } finally{
396
527
  if (timer) clearTimeout(timer);
397
528
  }
398
529
  }
530
+ /** A timed-out CLI call may leave Chromium wedged; force a clean relaunch. */ async reclaimBrowser(sandbox) {
531
+ try {
532
+ const command = await sandbox.runCommand({
533
+ cmd: 'sh',
534
+ args: [
535
+ '-c',
536
+ 'pkill -9 chromium || true; pkill -9 chrome || true'
537
+ ],
538
+ detached: true
539
+ });
540
+ let timer;
541
+ const timeout = new Promise((resolve)=>{
542
+ // Cleanup must not turn the original command timeout into an unbounded wait.
543
+ timer = setTimeout(resolve, 30_000);
544
+ if (typeof timer.unref === 'function') timer.unref();
545
+ });
546
+ try {
547
+ await Promise.race([
548
+ command.wait(),
549
+ timeout
550
+ ]);
551
+ } finally{
552
+ if (timer) clearTimeout(timer);
553
+ }
554
+ } catch {}
555
+ }
399
556
  async readFile(path) {
400
557
  const { stdout, exitCode, stderr } = await this.run('base64', [
401
558
  '-w0',
@@ -409,17 +566,156 @@ const BASE_ENV = {
409
566
  return Buffer.from(stdout.trim(), 'base64');
410
567
  }
411
568
  async writeFile(path, bytes) {
412
- await this.sandbox.writeFiles([
569
+ const sandbox = await this.acquire();
570
+ await sandbox.writeFiles([
413
571
  {
414
572
  path,
415
573
  content: bytes
416
574
  }
417
575
  ]);
418
576
  }
577
+ /** Restore a sliding idle window without cumulatively adding that window. */ async restoreLifetime(sandbox, timeoutMs) {
578
+ if (sandbox.status !== 'running') return;
579
+ const targetExpiresAt = Date.now() + timeoutMs;
580
+ const currentExpiresAt = sandbox.expiresAt?.getTime();
581
+ const extensionMs = currentExpiresAt === undefined ? timeoutMs : Math.ceil(targetExpiresAt - currentExpiresAt);
582
+ if (extensionMs > 0) await sandbox.extendTimeout(extensionMs);
583
+ }
584
+ async renewLifetime(timeoutMs) {
585
+ // Poll without resuming: a late heartbeat must not resurrect an already
586
+ // expired Sandbox whose in-memory browser state is gone.
587
+ const sandbox = await this.observe();
588
+ if (!sandbox) return;
589
+ await this.restoreLifetime(sandbox, timeoutMs);
590
+ }
591
+ keepalive(opts = {}) {
592
+ const timeoutMs = opts.timeoutMs ?? this.timeoutMs;
593
+ const intervalMs = opts.intervalMs ?? Math.min(5 * 60 * 1000, Math.floor(timeoutMs / 2));
594
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
595
+ throw new RangeError('keepalive timeoutMs must be greater than 0');
596
+ }
597
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0 || intervalMs >= timeoutMs) {
598
+ throw new RangeError('keepalive intervalMs must be greater than 0 and less than timeoutMs');
599
+ }
600
+ let stopped = false;
601
+ let renewing = false;
602
+ const tick = async ()=>{
603
+ if (stopped || renewing) return;
604
+ renewing = true;
605
+ try {
606
+ await this.renewLifetime(timeoutMs);
607
+ } catch (error) {
608
+ opts.onError?.(error);
609
+ } finally{
610
+ renewing = false;
611
+ }
612
+ };
613
+ const timer = setInterval(()=>void tick(), intervalMs);
614
+ timer.unref?.();
615
+ void tick();
616
+ const stop = ()=>{
617
+ if (stopped) return;
618
+ stopped = true;
619
+ clearInterval(timer);
620
+ this.keepaliveStops.delete(stop);
621
+ };
622
+ this.keepaliveStops.add(stop);
623
+ return stop;
624
+ }
419
625
  async close() {
420
- await this.sandbox.stop().catch(()=>{});
626
+ for (const stop of [
627
+ ...this.keepaliveStops
628
+ ])stop();
629
+ await this.destroyRuntime();
421
630
  }
422
631
  }
632
+ function stableNames(id) {
633
+ const hash = createHash('sha256').update(id).digest('hex');
634
+ return {
635
+ sandbox: `remote-browser-${hash.slice(0, 32)}`,
636
+ session: `session-${hash.slice(0, 32)}`
637
+ };
638
+ }
639
+ function isMissingSandbox(error) {
640
+ if (!(error instanceof Error)) return false;
641
+ const code = error.json?.error?.code;
642
+ return code === 'not_found' || error.message.includes('Status code 404');
643
+ }
644
+ class NamedSandboxRunner extends SandboxRunner {
645
+ constructor(opts){
646
+ const names = stableNames(opts.id);
647
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
648
+ super(undefined, {
649
+ ...BASE_ENV,
650
+ ...opts.env
651
+ }, timeoutMs), this.resetListeners = new Set(), this.onResume = async ()=>{
652
+ const event = {
653
+ id: this.id,
654
+ reason: 'resumed'
655
+ };
656
+ for (const listener of this.resetListeners){
657
+ try {
658
+ listener(event);
659
+ } catch {}
660
+ }
661
+ };
662
+ this.id = opts.id;
663
+ this.name = names.sandbox;
664
+ this.session = names.session;
665
+ this.image = opts.image ?? process.env.REMOTE_AGENT_BROWSER_IMAGE ?? DEFAULT_BROWSER_IMAGE;
666
+ this.vcpus = opts.vcpus ?? 2;
667
+ }
668
+ on(event, listener) {
669
+ if (event !== 'reset') throw new Error(`unsupported browser event: ${event}`);
670
+ this.resetListeners.add(listener);
671
+ return ()=>this.resetListeners.delete(listener);
672
+ }
673
+ async acquire() {
674
+ if (this.sandbox) return this.sandbox;
675
+ this.sandbox = await Sandbox.getOrCreate({
676
+ name: this.name,
677
+ image: this.image,
678
+ resources: {
679
+ vcpus: this.vcpus
680
+ },
681
+ timeout: this.timeoutMs,
682
+ ports: [],
683
+ env: this.env,
684
+ persistent: false,
685
+ onResume: this.onResume
686
+ });
687
+ return this.sandbox;
688
+ }
689
+ async observe() {
690
+ try {
691
+ const sandbox = await Sandbox.get({
692
+ name: this.name,
693
+ resume: false,
694
+ onResume: this.onResume
695
+ });
696
+ this.sandbox = sandbox;
697
+ return sandbox;
698
+ } catch (error) {
699
+ if (isMissingSandbox(error)) return null;
700
+ throw error;
701
+ }
702
+ }
703
+ async destroyRuntime() {
704
+ try {
705
+ const sandbox = this.sandbox ?? await Sandbox.get({
706
+ name: this.name,
707
+ resume: false
708
+ });
709
+ await sandbox.delete();
710
+ this.sandbox = undefined;
711
+ } catch (error) {
712
+ if (!isMissingSandbox(error)) throw error;
713
+ }
714
+ }
715
+ }
716
+ /** Create a lazy runner backed by a stable, project-scoped browser id. */ function createNamedBrowserSandboxRunner(opts) {
717
+ return new NamedSandboxRunner(opts);
718
+ }
423
719
  /**
424
720
  * Start an ephemeral Vercel Sandbox from the pre-built browser image.
425
721
  *
@@ -432,17 +728,18 @@ const BASE_ENV = {
432
728
  ...BASE_ENV,
433
729
  ...opts.env
434
730
  };
731
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
435
732
  const sandbox = await Sandbox.create({
436
733
  image,
437
734
  resources: {
438
735
  vcpus: opts.vcpus ?? 2
439
736
  },
440
- timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
737
+ timeout: timeoutMs,
441
738
  ports: [],
442
739
  env,
443
740
  persistent: false
444
741
  });
445
- return new SandboxRunner(sandbox, env);
742
+ return new SandboxRunner(sandbox, env, timeoutMs);
446
743
  }
447
744
 
448
745
  /**
@@ -476,14 +773,50 @@ const BASE_ENV = {
476
773
  return createBrowserClient(runner, {
477
774
  session: opts.session,
478
775
  args: opts.args,
776
+ proxy: opts.proxy,
777
+ ownsRunner: true
778
+ });
779
+ }
780
+ /**
781
+ * Create a lazy browser handle with a stable logical id.
782
+ *
783
+ * The first operation finds or creates a named Vercel Sandbox. Later handles
784
+ * created with the same id attach to that Sandbox, including from another
785
+ * process. If an expired Sandbox resumes, listeners are told that its
786
+ * in-memory page, refs, cookies, and console history were reset.
787
+ */ function createAgentBrowserSession(opts) {
788
+ if (!opts.id.trim()) throw new Error('browser id must not be empty');
789
+ const runner = createNamedBrowserSandboxRunner({
790
+ id: opts.id,
791
+ image: opts.image,
792
+ timeoutMs: opts.timeoutMs,
793
+ vcpus: opts.vcpus,
794
+ env: opts.env
795
+ });
796
+ const browser = createBrowserClient(runner, {
797
+ session: opts.session ?? runner.session,
798
+ args: opts.args,
799
+ proxy: opts.proxy,
800
+ closeSession: false,
479
801
  ownsRunner: true
480
802
  });
803
+ return {
804
+ ...browser,
805
+ id: opts.id,
806
+ on (event, listener) {
807
+ return runner.on(event, listener);
808
+ },
809
+ destroy () {
810
+ return browser.close();
811
+ }
812
+ };
481
813
  }
482
814
 
483
815
  // Public library surface: create an agent browser in a Vercel Sandbox and
484
816
  // drive it with the agent-browser CLI.
485
817
  /** Create AgentBrowser clients backed by a fresh Vercel Sandbox. */ const AgentBrowser = {
486
- create: createAgentBrowser
818
+ create: createAgentBrowser,
819
+ session: createAgentBrowserSession
487
820
  };
488
821
 
489
- export { AgentBrowser, createAgentBrowser, createBrowserClient };
822
+ export { AgentBrowser, createAgentBrowser, createAgentBrowserSession, createBrowserClient };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-agent-browser",
3
- "version": "1.0.0",
3
+ "version": "1.2.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": {
@@ -19,7 +19,7 @@
19
19
  "prepublishOnly": "node --run build",
20
20
  "typecheck": "tsc -p tsconfig.json --noEmit",
21
21
  "test": "node --run build && node --test tests/*.test.mjs",
22
- "test:integration": "node --run build && node --test tests/integration.test.mjs",
22
+ "test:integration": "node --run build && node --test --test-concurrency=1 tests/*.integration.ts",
23
23
  "test:unit": "node --run build && node --test tests/unit.test.mjs"
24
24
  },
25
25
  "files": [