remote-agent-browser 0.2.0 → 1.1.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:
@@ -128,4 +153,24 @@ await writeFile('capture.har', result.file.bytes)
128
153
 
129
154
  All methods use the same disposable browser session until `close()` is called.
130
155
 
156
+ ### Keepalive
157
+
158
+ Keep the Sandbox alive across idle gaps during long-running work. Always stop
159
+ the heartbeat in a `finally` block; after it stops, the Sandbox expires at its
160
+ normal timeout unless it is closed earlier:
161
+
162
+ ```ts
163
+ const stopKeepalive = browser.keepalive()
164
+ try {
165
+ await runLongAgentTurn(browser)
166
+ } finally {
167
+ stopKeepalive()
168
+ }
169
+ ```
170
+
171
+ By default, each heartbeat restores the wall-clock timeout configured by
172
+ `AgentBrowser.create()` and runs halfway through that window, capped at five
173
+ minutes. Override either value with `timeoutMs` and `intervalMs`. Renewal is
174
+ best-effort; pass `onError` to observe failures.
175
+
131
176
  Container image and development details are in [docs.md](./docs.md).
package/dist/index.d.ts CHANGED
@@ -60,13 +60,29 @@ type ExecOptions = {
60
60
  type JsonExecOptions = Omit<ExecOptions, 'output'> & {
61
61
  output: 'json';
62
62
  };
63
+ type ProxyOptions = string | {
64
+ /** Proxy server URL, optionally including authentication. */
65
+ url: string;
66
+ /** Host patterns that should connect directly instead of using the proxy. */
67
+ bypass?: string | readonly string[];
68
+ };
63
69
  type BrowserClientOptions = {
64
70
  session?: string;
65
71
  /** agent-browser CLI arguments inserted before every command. */
66
72
  args?: string[];
73
+ /** Proxy configuration fixed for this client's lifetime. */
74
+ proxy?: ProxyOptions;
67
75
  /** Whether close() also tears down the command runner. Default: true. */
68
76
  ownsRunner?: boolean;
69
77
  };
78
+ type KeepaliveOptions = {
79
+ /** Remaining sandbox lifetime to maintain. Defaults to its creation timeout. */
80
+ timeoutMs?: number;
81
+ /** Heartbeat cadence. Defaults to half the timeout, capped at 5 minutes. */
82
+ intervalMs?: number;
83
+ /** Optional observer for best-effort heartbeat failures. */
84
+ onError?: (error: unknown) => void;
85
+ };
70
86
  /**
71
87
  * Minimal command executor the browser client needs. Implemented by the
72
88
  * Vercel Sandbox adapter; anything with the same shape can back a
@@ -88,6 +104,8 @@ interface CommandRunner {
88
104
  writeFile(path: string, bytes: Buffer): Promise<void>;
89
105
  /** Tear the remote environment down. */
90
106
  close(): Promise<void>;
107
+ /** Keep the remote environment alive until the returned stop function runs. */
108
+ keepalive?(opts?: KeepaliveOptions): () => void;
91
109
  }
92
110
  /** Session-oriented remote browser client. */
93
111
  interface AgentBrowser$1 {
@@ -115,6 +133,11 @@ interface AgentBrowser$1 {
115
133
  png: Buffer;
116
134
  result: BrowserRunResult;
117
135
  }>;
136
+ /**
137
+ * Keep the backing Sandbox alive across idle gaps. Call the returned stop
138
+ * function in a finally block so the Sandbox can expire when work finishes.
139
+ */
140
+ keepalive(opts?: KeepaliveOptions): () => void;
118
141
  /** Close the browser session (and the sandbox when it owns it). */
119
142
  close(): Promise<void>;
120
143
  }
@@ -135,6 +158,8 @@ type CreateAgentBrowserOptions = {
135
158
  env?: Record<string, string>;
136
159
  /** agent-browser CLI arguments inserted before every command. */
137
160
  args?: string[];
161
+ /** Proxy configuration fixed for this browser's lifetime. */
162
+ proxy?: ProxyOptions;
138
163
  };
139
164
  /**
140
165
  * Create an agent-browser client running inside a fresh Vercel Sandbox.
@@ -177,4 +202,4 @@ declare const AgentBrowser: {
177
202
  type AgentBrowser = AgentBrowser$1;
178
203
 
179
204
  export { AgentBrowser, createAgentBrowser, createBrowserClient };
180
- export type { BrowserClientOptions, BrowserCommandResult, BrowserRunResult, CommandRunner, CreateAgentBrowserOptions, ExecOptions, JsonExecOptions, RunOptions };
205
+ export type { BrowserClientOptions, BrowserCommandResult, BrowserRunResult, CommandRunner, CreateAgentBrowserOptions, ExecOptions, JsonExecOptions, KeepaliveOptions, ProxyOptions, RunOptions };
package/dist/index.js CHANGED
@@ -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,6 +144,11 @@ 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;
115
153
  const recordings = new Map();
116
154
  let closed = false;
@@ -150,15 +188,25 @@ function outputSpec(args) {
150
188
  remotePath = recording?.path;
151
189
  fileSpec = recording?.spec;
152
190
  }
153
- const run = await runner.run('agent-browser', finalArgs, {
154
- timeoutMs
155
- });
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
+ }
156
204
  const result = {
157
205
  args,
158
206
  ok: run.exitCode === 0,
159
207
  exitCode: run.exitCode,
160
- stdout: run.stdout,
161
- stderr: run.stderr
208
+ stdout: redactProxy(run.stdout),
209
+ stderr: redactProxy(run.stderr)
162
210
  };
163
211
  if (result.ok && startsRecording && remotePath && fileSpec) {
164
212
  recordings.set(useSession, {
@@ -333,6 +381,13 @@ function outputSpec(args) {
333
381
  result: run
334
382
  };
335
383
  },
384
+ keepalive (opts = {}) {
385
+ assertOpen();
386
+ if (!runner.keepalive) {
387
+ throw new Error('the command runner does not support keepalive');
388
+ }
389
+ return runner.keepalive(opts);
390
+ },
336
391
  async close () {
337
392
  if (closed) return;
338
393
  closed = true;
@@ -340,7 +395,8 @@ function outputSpec(args) {
340
395
  await runner.run('agent-browser', withSession(session, clientArgs, [
341
396
  'close'
342
397
  ]), {
343
- timeoutMs: 15_000
398
+ timeoutMs: 15_000,
399
+ env: clientEnv
344
400
  }).catch(()=>{});
345
401
  if (ownsRunner) await runner.close();
346
402
  }
@@ -354,9 +410,11 @@ const BASE_ENV = {
354
410
  AGENT_BROWSER_DEFAULT_TIMEOUT: '25000'
355
411
  };
356
412
  /** CommandRunner backed by one Vercel Sandbox. */ class SandboxRunner {
357
- constructor(sandbox, env){
413
+ constructor(sandbox, env, timeoutMs = DEFAULT_TIMEOUT_MS){
414
+ this.keepaliveStops = new Set();
358
415
  this.sandbox = sandbox;
359
416
  this.env = env;
417
+ this.timeoutMs = timeoutMs;
360
418
  }
361
419
  async run(cmd, args, opts = {}) {
362
420
  const timeoutMs = opts.timeoutMs ?? 60_000;
@@ -416,7 +474,58 @@ const BASE_ENV = {
416
474
  }
417
475
  ]);
418
476
  }
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
+ });
484
+ if (sandbox.status !== 'running') return;
485
+ this.sandbox = sandbox;
486
+ const targetExpiresAt = Date.now() + timeoutMs;
487
+ const currentExpiresAt = sandbox.expiresAt?.getTime();
488
+ const extensionMs = currentExpiresAt === undefined ? timeoutMs : Math.ceil(targetExpiresAt - currentExpiresAt);
489
+ if (extensionMs > 0) await sandbox.extendTimeout(extensionMs);
490
+ }
491
+ keepalive(opts = {}) {
492
+ const timeoutMs = opts.timeoutMs ?? this.timeoutMs;
493
+ const intervalMs = opts.intervalMs ?? Math.min(5 * 60 * 1000, Math.floor(timeoutMs / 2));
494
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
495
+ throw new RangeError('keepalive timeoutMs must be greater than 0');
496
+ }
497
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0 || intervalMs >= timeoutMs) {
498
+ throw new RangeError('keepalive intervalMs must be greater than 0 and less than timeoutMs');
499
+ }
500
+ let stopped = false;
501
+ let renewing = false;
502
+ const tick = async ()=>{
503
+ if (stopped || renewing) return;
504
+ renewing = true;
505
+ try {
506
+ await this.renewLifetime(timeoutMs);
507
+ } catch (error) {
508
+ opts.onError?.(error);
509
+ } finally{
510
+ renewing = false;
511
+ }
512
+ };
513
+ const timer = setInterval(()=>void tick(), intervalMs);
514
+ timer.unref?.();
515
+ void tick();
516
+ const stop = ()=>{
517
+ if (stopped) return;
518
+ stopped = true;
519
+ clearInterval(timer);
520
+ this.keepaliveStops.delete(stop);
521
+ };
522
+ this.keepaliveStops.add(stop);
523
+ return stop;
524
+ }
419
525
  async close() {
526
+ for (const stop of [
527
+ ...this.keepaliveStops
528
+ ])stop();
420
529
  await this.sandbox.stop().catch(()=>{});
421
530
  }
422
531
  }
@@ -432,17 +541,18 @@ const BASE_ENV = {
432
541
  ...BASE_ENV,
433
542
  ...opts.env
434
543
  };
544
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
435
545
  const sandbox = await Sandbox.create({
436
546
  image,
437
547
  resources: {
438
548
  vcpus: opts.vcpus ?? 2
439
549
  },
440
- timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
550
+ timeout: timeoutMs,
441
551
  ports: [],
442
552
  env,
443
553
  persistent: false
444
554
  });
445
- return new SandboxRunner(sandbox, env);
555
+ return new SandboxRunner(sandbox, env, timeoutMs);
446
556
  }
447
557
 
448
558
  /**
@@ -476,6 +586,7 @@ const BASE_ENV = {
476
586
  return createBrowserClient(runner, {
477
587
  session: opts.session,
478
588
  args: opts.args,
589
+ proxy: opts.proxy,
479
590
  ownsRunner: true
480
591
  });
481
592
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-agent-browser",
3
- "version": "0.2.0",
3
+ "version": "1.1.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": {
@@ -8,22 +8,18 @@
8
8
  "url": "git+https://github.com/vercel-labs/remote-agent-browser.git"
9
9
  },
10
10
  "type": "module",
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
11
13
  "packageManager": "pnpm@11.18.0",
12
14
  "publishConfig": {
13
15
  "access": "public"
14
16
  },
15
- "exports": {
16
- ".": {
17
- "types": "./dist/index.d.ts",
18
- "import": "./dist/index.js"
19
- }
20
- },
21
17
  "scripts": {
22
18
  "build": "bunchee --runtime node",
23
19
  "prepublishOnly": "node --run build",
24
20
  "typecheck": "tsc -p tsconfig.json --noEmit",
25
21
  "test": "node --run build && node --test tests/*.test.mjs",
26
- "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",
27
23
  "test:unit": "node --run build && node --test tests/unit.test.mjs"
28
24
  },
29
25
  "files": [