remote-agent-browser 0.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.
@@ -0,0 +1,19 @@
1
+ FROM node:24-slim
2
+
3
+ # Keep the browser and its native CLI in the image so Sandbox creation never
4
+ # installs packages at runtime. Debian Chromium supports amd64 and arm64.
5
+ RUN apt-get update \
6
+ && apt-get install -y --no-install-recommends \
7
+ chromium \
8
+ fonts-liberation \
9
+ fonts-noto-color-emoji \
10
+ && rm -rf /var/lib/apt/lists/* \
11
+ && npm install -g --allow-scripts=agent-browser agent-browser
12
+
13
+ ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
14
+ ENV AGENT_BROWSER_ARGS="--no-sandbox,--disable-dev-shm-usage,--disable-gpu"
15
+ ENV AGENT_BROWSER_DEFAULT_TIMEOUT=25000
16
+
17
+ # Vercel Sandbox uses bash to execute commands in custom images.
18
+ WORKDIR /vercel/sandbox
19
+ CMD ["/bin/bash"]
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # remote-agent-browser
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.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add remote-agent-browser
11
+ ```
12
+
13
+ ## Use
14
+
15
+ ```ts
16
+ import { createAgentBrowser } from 'remote-agent-browser'
17
+
18
+ const browser = await createAgentBrowser()
19
+
20
+ try {
21
+ const { results } = await browser.run([
22
+ ['open', 'https://example.com'],
23
+ ['snapshot', '-i', '--json'],
24
+ ])
25
+
26
+ console.log(results[1].stdout)
27
+ } finally {
28
+ await browser.close()
29
+ }
30
+ ```
31
+
32
+ `createAgentBrowser()` starts a fresh Vercel Sandbox from the prebuilt browser
33
+ image. Commands in one client share the same page, cookies, tabs, and element
34
+ references. `close()` closes Chromium and stops the Sandbox.
35
+
36
+ ## Authentication
37
+
38
+ Authentication is automatic when running on Vercel through
39
+ `VERCEL_OIDC_TOKEN`.
40
+
41
+ For local development, link a Vercel project and pull its environment:
42
+
43
+ ```bash
44
+ vercel link
45
+ vercel env pull .env.local
46
+ node --env-file=.env.local example.mjs
47
+ ```
48
+
49
+ ## API
50
+
51
+ ### `createAgentBrowser()`
52
+
53
+ Starts a fresh browser in a disposable Vercel Sandbox. Always call
54
+ `browser.close()` when finished.
55
+
56
+ ### `browser.run(commands)`
57
+
58
+ Run several agent-browser commands in the same session:
59
+
60
+ ```ts
61
+ const result = await browser.run([
62
+ ['open', 'https://my-preview.vercel.app'],
63
+ ['wait', '--load', 'networkidle'],
64
+ ['snapshot', '-i', '--json'],
65
+ ['click', '@e3'],
66
+ ])
67
+ ```
68
+
69
+ ### `browser.exec(command, options?)`
70
+
71
+ Run one command with arguments and flags:
72
+
73
+ ```ts
74
+ await browser.exec('find', {
75
+ args: ['role', 'button', 'click'],
76
+ flags: { name: 'Submit' },
77
+ })
78
+ ```
79
+
80
+ ### Convenience methods
81
+
82
+ - `browser.snapshot(url)` opens a page and returns its interactive snapshot.
83
+ - `browser.screenshot(url, { fullPage: true })` returns a PNG buffer.
84
+ - `browser.close()` closes the session and stops the Sandbox.
85
+
86
+ All methods use the same disposable browser session until `close()` is called.
87
+
88
+ Container image and development details are in [docs.md](./docs.md).
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Shared types for remote-agent-browser.
3
+ *
4
+ * The library has two layers:
5
+ * - `AgentBrowser` (browser.ts): a thin, session-oriented client over the
6
+ * `agent-browser` CLI running somewhere that can execute commands.
7
+ * - `createAgentBrowser` (create.ts): provisions a Vercel Sandbox from a
8
+ * browser-ready image and returns an `AgentBrowser` backed by it.
9
+ */
10
+ /** One agent-browser CLI invocation result. */
11
+ type BrowserCommandResult = {
12
+ /** The CLI args that ran, e.g. ["snapshot", "-i", "--json"] */
13
+ args: string[];
14
+ ok: boolean;
15
+ exitCode: number | null;
16
+ stdout: string;
17
+ stderr: string;
18
+ /** File bytes collected from the sandbox for file-producing commands. */
19
+ file?: {
20
+ path: string;
21
+ bytes: Buffer;
22
+ contentType: string;
23
+ };
24
+ };
25
+ /** A batch of commands run sequentially in one session. */
26
+ type BrowserRunResult = {
27
+ session: string;
28
+ results: BrowserCommandResult[];
29
+ ok: boolean;
30
+ };
31
+ type RunOptions = {
32
+ /** Named session to reuse browser state across calls. Default: ephemeral. */
33
+ session?: string;
34
+ /** Stop the batch at the first failing command. Default: true. */
35
+ stopOnError?: boolean;
36
+ /** Per-command timeout in ms. */
37
+ timeoutMs?: number;
38
+ };
39
+ type ExecOptions = {
40
+ session?: string;
41
+ /** Positional CLI args, e.g. ["@e3"] for click. */
42
+ args?: string[];
43
+ /** Flags serialized to --kebab-case (true → bare flag, arrays → repeated). */
44
+ flags?: Record<string, unknown>;
45
+ timeoutMs?: number;
46
+ };
47
+ /**
48
+ * Minimal command executor the browser client needs. Implemented by the
49
+ * Vercel Sandbox adapter; anything with the same shape can back a
50
+ * AgentBrowser (tests, a local shell, another sandbox provider).
51
+ */
52
+ interface CommandRunner {
53
+ /** Run a command, waiting for completion. */
54
+ run(cmd: string, args: string[], opts?: {
55
+ timeoutMs?: number;
56
+ env?: Record<string, string>;
57
+ }): Promise<{
58
+ stdout: string;
59
+ stderr: string;
60
+ exitCode: number | null;
61
+ }>;
62
+ /** Read a file's bytes from the remote environment. */
63
+ readFile(path: string): Promise<Buffer>;
64
+ /** Tear the remote environment down. */
65
+ close(): Promise<void>;
66
+ }
67
+ /** Session-oriented remote browser client. */
68
+ interface AgentBrowser {
69
+ /** The runner's session name this client uses by default. */
70
+ readonly session: string;
71
+ /** Run a batch of agent-browser arg-arrays sequentially. */
72
+ run(commands: string[][], opts?: RunOptions): Promise<BrowserRunResult>;
73
+ /** Run one agent-browser command by name. */
74
+ exec(command: string, opts?: ExecOptions): Promise<BrowserCommandResult>;
75
+ /** Open a URL and return the interactive accessibility snapshot (JSON). */
76
+ snapshot(url: string, opts?: RunOptions): Promise<BrowserRunResult>;
77
+ /** Open a URL and return a PNG screenshot. */
78
+ screenshot(url: string, opts?: RunOptions & {
79
+ fullPage?: boolean;
80
+ }): Promise<{
81
+ png: Buffer;
82
+ result: BrowserRunResult;
83
+ }>;
84
+ /** Close the browser session (and the sandbox when it owns it). */
85
+ close(): Promise<void>;
86
+ }
87
+
88
+ type CreateAgentBrowserOptions = {
89
+ /** Named CLI session; state (page, cookies, refs) persists across calls. */
90
+ session?: string;
91
+ /**
92
+ * Vercel Container Registry image containing agent-browser + Chromium.
93
+ * Default: REMOTE_AGENT_BROWSER_IMAGE or remote-agent-browser:latest.
94
+ */
95
+ image?: string;
96
+ /** Sandbox wall-clock timeout in ms. Default: 10 minutes. */
97
+ timeoutMs?: number;
98
+ /** Sandbox vCPUs. Default: 2. */
99
+ vcpus?: number;
100
+ /** Extra env vars for every command inside the sandbox. */
101
+ env?: Record<string, string>;
102
+ };
103
+ /**
104
+ * Create an agent-browser client running inside a fresh Vercel Sandbox.
105
+ *
106
+ * The sandbox boots from a browser-ready VCR image and the returned client
107
+ * runs every command as
108
+ * `agent-browser --session <name> …` so page state persists across calls.
109
+ * `close()` closes the browser and stops the sandbox.
110
+ *
111
+ * Auth comes from the environment (VERCEL_OIDC_TOKEN on Vercel, or a local
112
+ * `vercel login`), via @vercel/sandbox.
113
+ *
114
+ * ```ts
115
+ * import { createAgentBrowser } from 'remote-agent-browser'
116
+ *
117
+ * const browser = await createAgentBrowser()
118
+ * const { results } = await browser.run([
119
+ * ['open', 'https://example.com'],
120
+ * ['snapshot', '-i', '--json'],
121
+ * ])
122
+ * await browser.close()
123
+ * ```
124
+ */
125
+ declare function createAgentBrowser(opts?: CreateAgentBrowserOptions): Promise<AgentBrowser>;
126
+
127
+ /**
128
+ * Create a session-oriented browser client over any CommandRunner.
129
+ *
130
+ * Every command runs as `agent-browser --session <name> <args...>` so the
131
+ * remote daemon keeps page state (refs, cookies, tabs) across calls. Pass an
132
+ * explicit session name to share state across clients, or let it default to
133
+ * an ephemeral per-client session that is closed on `close()`.
134
+ */
135
+ declare function createBrowserClient(runner: CommandRunner, opts?: {
136
+ session?: string;
137
+ ownsRunner?: boolean;
138
+ }): AgentBrowser;
139
+
140
+ export { createAgentBrowser, createBrowserClient };
141
+ export type { AgentBrowser, BrowserCommandResult, BrowserRunResult, CommandRunner, CreateAgentBrowserOptions, ExecOptions, RunOptions };
package/dist/index.js ADDED
@@ -0,0 +1,332 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { Sandbox } from '@vercel/sandbox';
3
+
4
+ /** Commands whose output is a file we pull back as bytes. */ const FILE_OUTPUT = {
5
+ screenshot: {
6
+ subcommand: null,
7
+ ext: 'png',
8
+ contentType: 'image/png'
9
+ },
10
+ pdf: {
11
+ subcommand: null,
12
+ ext: 'pdf',
13
+ contentType: 'application/pdf'
14
+ },
15
+ trace: {
16
+ subcommand: 'stop',
17
+ ext: 'json',
18
+ contentType: 'application/json'
19
+ },
20
+ profiler: {
21
+ subcommand: 'stop',
22
+ ext: 'json',
23
+ contentType: 'application/json'
24
+ },
25
+ record: {
26
+ subcommand: 'stop',
27
+ ext: 'webm',
28
+ contentType: 'video/webm'
29
+ }
30
+ };
31
+ const SESSION_NAME = /^[\w.-]{1,64}$/;
32
+ /** { json: true, fullPage: true, depth: 2 } → ["--json", "--full-page", "--depth", "2"] */ function flagsToArgs(flags) {
33
+ const args = [];
34
+ for (const [key, value] of Object.entries(flags)){
35
+ if (value === false || value === null || value === undefined) continue;
36
+ const flag = `--${key.replace(/[A-Z]/g, (c)=>`-${c.toLowerCase()}`)}`;
37
+ const values = Array.isArray(value) ? value : [
38
+ value
39
+ ];
40
+ for (const item of values){
41
+ if (item === false || item === null || item === undefined) continue;
42
+ args.push(flag);
43
+ if (item !== true) args.push(String(item));
44
+ }
45
+ }
46
+ return args;
47
+ }
48
+ function withSession(session, args) {
49
+ return [
50
+ '--session',
51
+ session,
52
+ ...args
53
+ ];
54
+ }
55
+ /**
56
+ * Create a session-oriented browser client over any CommandRunner.
57
+ *
58
+ * Every command runs as `agent-browser --session <name> <args...>` so the
59
+ * remote daemon keeps page state (refs, cookies, tabs) across calls. Pass an
60
+ * explicit session name to share state across clients, or let it default to
61
+ * an ephemeral per-client session that is closed on `close()`.
62
+ */ function createBrowserClient(runner, opts = {}) {
63
+ const session = opts.session ?? `session-${randomUUID()}`;
64
+ if (!SESSION_NAME.test(session)) {
65
+ throw new Error(`session must match [A-Za-z0-9_.-]{1,64}, got "${session}"`);
66
+ }
67
+ const ownsRunner = opts.ownsRunner ?? true;
68
+ let closed = false;
69
+ function assertOpen() {
70
+ if (closed) throw new Error('AgentBrowser is closed');
71
+ }
72
+ async function execCommand(args, timeoutMs, useSession = session) {
73
+ const [name, ...rest] = args;
74
+ // File-producing commands: inject a remote path and pull the bytes back,
75
+ // unless the caller already supplied an output path themselves.
76
+ const fileSpec = name ? FILE_OUTPUT[name] : undefined;
77
+ const positionals = rest.filter((a)=>!a.startsWith('-'));
78
+ const wantsFile = fileSpec && (fileSpec.subcommand === null ? positionals.length === 0 : positionals.length === 1 && positionals[0] === fileSpec.subcommand);
79
+ let finalArgs = withSession(useSession, args);
80
+ let remotePath;
81
+ if (wantsFile) {
82
+ remotePath = `/tmp/agent-browser-${randomUUID()}.${fileSpec.ext}`;
83
+ // screenshot <path> is the first positional for these commands
84
+ finalArgs = withSession(useSession, [
85
+ name,
86
+ ...rest,
87
+ remotePath
88
+ ]);
89
+ }
90
+ const run = await runner.run('agent-browser', finalArgs, {
91
+ timeoutMs
92
+ });
93
+ const result = {
94
+ args,
95
+ ok: run.exitCode === 0,
96
+ exitCode: run.exitCode,
97
+ stdout: run.stdout,
98
+ stderr: run.stderr
99
+ };
100
+ if (result.ok && remotePath && fileSpec) {
101
+ try {
102
+ result.file = {
103
+ path: remotePath,
104
+ bytes: await runner.readFile(remotePath),
105
+ contentType: fileSpec.contentType
106
+ };
107
+ } catch (error) {
108
+ result.ok = false;
109
+ result.stderr += `\ncommand succeeded but ${remotePath} could not be read: ${error instanceof Error ? error.message : String(error)}`;
110
+ }
111
+ }
112
+ return result;
113
+ }
114
+ return {
115
+ session,
116
+ async run (commands, runOpts = {}) {
117
+ assertOpen();
118
+ const useSession = runOpts.session ?? session;
119
+ if (!SESSION_NAME.test(useSession)) {
120
+ throw new Error(`session must match [A-Za-z0-9_.-]{1,64}, got "${useSession}"`);
121
+ }
122
+ const stopOnError = runOpts.stopOnError ?? true;
123
+ const results = [];
124
+ // Run each command inside the same CLI session so state carries over.
125
+ for (const command of commands){
126
+ if (command.length === 0) {
127
+ results.push({
128
+ args: [],
129
+ ok: false,
130
+ exitCode: null,
131
+ stdout: '',
132
+ stderr: 'empty command'
133
+ });
134
+ if (stopOnError) break;
135
+ continue;
136
+ }
137
+ const result = await execCommand(command, runOpts.timeoutMs, useSession);
138
+ results.push(result);
139
+ if (!result.ok && stopOnError) break;
140
+ }
141
+ return {
142
+ session: useSession,
143
+ results,
144
+ ok: results.every((r)=>r.ok)
145
+ };
146
+ },
147
+ async exec (command, execOpts = {}) {
148
+ assertOpen();
149
+ const args = [
150
+ command,
151
+ ...execOpts.args ?? [],
152
+ ...execOpts.flags ? flagsToArgs(execOpts.flags) : []
153
+ ];
154
+ const useSession = execOpts.session ?? session;
155
+ if (!SESSION_NAME.test(useSession)) {
156
+ throw new Error(`session must match [A-Za-z0-9_.-]{1,64}, got "${useSession}"`);
157
+ }
158
+ return execCommand(args, execOpts.timeoutMs, useSession);
159
+ },
160
+ async snapshot (url, runOpts = {}) {
161
+ return this.run([
162
+ [
163
+ 'open',
164
+ url
165
+ ],
166
+ [
167
+ 'snapshot',
168
+ '-i',
169
+ '--json'
170
+ ]
171
+ ], runOpts);
172
+ },
173
+ async screenshot (url, runOpts = {}) {
174
+ const shotArgs = [
175
+ 'screenshot'
176
+ ];
177
+ if (runOpts.fullPage) shotArgs.push('--full');
178
+ const run = await this.run([
179
+ [
180
+ 'open',
181
+ url
182
+ ],
183
+ shotArgs
184
+ ], runOpts);
185
+ const file = run.results.find((r)=>r.file)?.file;
186
+ if (!file) {
187
+ throw new Error(`screenshot failed: ${run.results.map((r)=>r.stderr || r.stdout).join('\n') || 'no output'}`);
188
+ }
189
+ return {
190
+ png: file.bytes,
191
+ result: run
192
+ };
193
+ },
194
+ async close () {
195
+ if (closed) return;
196
+ closed = true;
197
+ // Close the CLI session's browser first so the sandbox can stop cleanly.
198
+ await runner.run('agent-browser', withSession(session, [
199
+ 'close'
200
+ ]), {
201
+ timeoutMs: 15_000
202
+ }).catch(()=>{});
203
+ if (ownsRunner) await runner.close();
204
+ }
205
+ };
206
+ }
207
+
208
+ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
209
+ const DEFAULT_BROWSER_IMAGE = 'remote-agent-browser:latest';
210
+ const BASE_ENV = {
211
+ AGENT_BROWSER_ARGS: '--no-sandbox,--disable-dev-shm-usage,--disable-gpu',
212
+ AGENT_BROWSER_DEFAULT_TIMEOUT: '25000'
213
+ };
214
+ /** CommandRunner backed by one Vercel Sandbox. */ class SandboxRunner {
215
+ constructor(sandbox, env){
216
+ this.sandbox = sandbox;
217
+ this.env = env;
218
+ }
219
+ async run(cmd, args, opts = {}) {
220
+ const timeoutMs = opts.timeoutMs ?? 60_000;
221
+ const command = await this.sandbox.runCommand({
222
+ cmd,
223
+ args,
224
+ env: {
225
+ ...this.env,
226
+ ...opts.env
227
+ },
228
+ detached: true
229
+ });
230
+ let timer;
231
+ const timeout = new Promise((_, reject)=>{
232
+ timer = setTimeout(()=>reject(new Error(`command timed out after ${timeoutMs}ms`)), timeoutMs);
233
+ if (typeof timer.unref === 'function') timer.unref();
234
+ });
235
+ try {
236
+ const finished = await Promise.race([
237
+ command.wait(),
238
+ timeout
239
+ ]);
240
+ const exitCode = typeof finished === 'number' ? finished : finished.exitCode;
241
+ const [stdout, stderr] = await Promise.all([
242
+ command.output('stdout'),
243
+ command.output('stderr')
244
+ ]);
245
+ return {
246
+ stdout,
247
+ stderr,
248
+ exitCode
249
+ };
250
+ } catch (error) {
251
+ await command.kill().catch(()=>{});
252
+ throw error;
253
+ } finally{
254
+ if (timer) clearTimeout(timer);
255
+ }
256
+ }
257
+ async readFile(path) {
258
+ const { stdout, exitCode, stderr } = await this.run('base64', [
259
+ '-w0',
260
+ path
261
+ ], {
262
+ timeoutMs: 30_000
263
+ });
264
+ if (exitCode !== 0) {
265
+ throw new Error(`failed to read ${path}: ${stderr.trim()}`);
266
+ }
267
+ return Buffer.from(stdout.trim(), 'base64');
268
+ }
269
+ async close() {
270
+ await this.sandbox.stop().catch(()=>{});
271
+ }
272
+ }
273
+ /**
274
+ * Start an ephemeral Vercel Sandbox from the pre-built browser image.
275
+ *
276
+ * The image must already exist in the Sandbox project's Vercel Container
277
+ * Registry and contain both `agent-browser` and Chromium. A repository name,
278
+ * tag, digest, or fully-qualified VCR reference is accepted by the SDK.
279
+ */ async function provisionBrowserSandbox(opts) {
280
+ const image = opts.image ?? process.env.REMOTE_AGENT_BROWSER_IMAGE ?? DEFAULT_BROWSER_IMAGE;
281
+ const env = {
282
+ ...BASE_ENV,
283
+ ...opts.env
284
+ };
285
+ const sandbox = await Sandbox.create({
286
+ image,
287
+ resources: {
288
+ vcpus: opts.vcpus ?? 2
289
+ },
290
+ timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
291
+ ports: [],
292
+ env,
293
+ persistent: false
294
+ });
295
+ return new SandboxRunner(sandbox, env);
296
+ }
297
+
298
+ /**
299
+ * Create an agent-browser client running inside a fresh Vercel Sandbox.
300
+ *
301
+ * The sandbox boots from a browser-ready VCR image and the returned client
302
+ * runs every command as
303
+ * `agent-browser --session <name> …` so page state persists across calls.
304
+ * `close()` closes the browser and stops the sandbox.
305
+ *
306
+ * Auth comes from the environment (VERCEL_OIDC_TOKEN on Vercel, or a local
307
+ * `vercel login`), via @vercel/sandbox.
308
+ *
309
+ * ```ts
310
+ * import { createAgentBrowser } from 'remote-agent-browser'
311
+ *
312
+ * const browser = await createAgentBrowser()
313
+ * const { results } = await browser.run([
314
+ * ['open', 'https://example.com'],
315
+ * ['snapshot', '-i', '--json'],
316
+ * ])
317
+ * await browser.close()
318
+ * ```
319
+ */ async function createAgentBrowser(opts = {}) {
320
+ const runner = await provisionBrowserSandbox({
321
+ image: opts.image,
322
+ timeoutMs: opts.timeoutMs,
323
+ vcpus: opts.vcpus,
324
+ env: opts.env
325
+ });
326
+ return createBrowserClient(runner, {
327
+ session: opts.session,
328
+ ownsRunner: true
329
+ });
330
+ }
331
+
332
+ export { createAgentBrowser, createBrowserClient };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "remote-agent-browser",
3
+ "version": "0.1.0",
4
+ "description": "Run agent-browser in a Vercel Sandbox created from a browser-ready container image",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/vercel-labs/remote-agent-browser.git"
9
+ },
10
+ "type": "module",
11
+ "packageManager": "pnpm@11.18.0",
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js"
19
+ }
20
+ },
21
+ "scripts": {
22
+ "build": "bunchee --runtime node",
23
+ "prepublishOnly": "node --run build",
24
+ "typecheck": "tsc -p tsconfig.json --noEmit",
25
+ "test": "node --run build && node --test tests/*.test.mjs",
26
+ "test:integration": "node --run build && node --test tests/integration.test.mjs",
27
+ "test:unit": "node --run build && node --test tests/unit.test.mjs"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "Dockerfile.sandbox"
32
+ ],
33
+ "dependencies": {
34
+ "@vercel/sandbox": "^2.9.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^24.0.0",
38
+ "bunchee": "^7.0.0",
39
+ "typescript": "^5.9.0"
40
+ }
41
+ }