remote-agent-browser 0.1.0 → 0.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
@@ -12,27 +12,17 @@ pnpm add remote-agent-browser
12
12
 
13
13
  ## Use
14
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
15
+ `AgentBrowser.create()` starts a fresh Vercel Sandbox from the prebuilt browser
33
16
  image. Commands in one client share the same page, cookies, tabs, and element
34
17
  references. `close()` closes Chromium and stops the Sandbox.
35
18
 
19
+ ## Examples
20
+
21
+ - [Capture a screenshot](./examples/screenshot/README.md) — capture a full-page
22
+ PNG and save it locally.
23
+ - [Agent Bash tool integration](./examples/agent-bash-tool/README.md) — let an
24
+ agent use normal `agent-browser <command>` invocations through its Bash tool.
25
+
36
26
  ## Authentication
37
27
 
38
28
  Authentication is automatic when running on Vercel through
@@ -43,16 +33,26 @@ For local development, link a Vercel project and pull its environment:
43
33
  ```bash
44
34
  vercel link
45
35
  vercel env pull .env.local
46
- node --env-file=.env.local example.mjs
36
+ node --env-file=.env.local examples/screenshot/index.mjs
47
37
  ```
48
38
 
49
39
  ## API
50
40
 
51
- ### `createAgentBrowser()`
41
+ ### `AgentBrowser.create()`
52
42
 
53
43
  Starts a fresh browser in a disposable Vercel Sandbox. Always call
54
44
  `browser.close()` when finished.
55
45
 
46
+ Pass agent-browser global options with `args`. They are placed before
47
+ every command, which is required for launch settings such as color scheme,
48
+ profiles, proxies, and init scripts:
49
+
50
+ ```ts
51
+ const browser = await AgentBrowser.create({
52
+ args: ['--color-scheme', 'dark', '--enable', 'react-devtools'],
53
+ })
54
+ ```
55
+
56
56
  ### `browser.run(commands)`
57
57
 
58
58
  Run several agent-browser commands in the same session:
@@ -77,10 +77,53 @@ await browser.exec('find', {
77
77
  })
78
78
  ```
79
79
 
80
+ ### Typed JSON
81
+
82
+ Set `output: 'json'` for commands that support `--json`. `exec<T>()` adds the
83
+ flag, unwraps the CLI response envelope, and keeps the normal command fields
84
+ alongside the typed `data` value:
85
+
86
+ ```ts
87
+ type UrlResult = { url: string }
88
+
89
+ const result = await browser.exec<UrlResult>('get', {
90
+ args: ['url'],
91
+ output: 'json',
92
+ })
93
+ console.log(result.data.url, result.ok)
94
+ ```
95
+
96
+ ### File transfer
97
+
98
+ Upload local buffers through a page's file input, or collect a browser download
99
+ without exposing the Sandbox filesystem:
100
+
101
+ ```ts
102
+ await browser.upload('#avatar', [
103
+ { name: 'avatar.png', bytes: await readFile('avatar.png') },
104
+ ])
105
+
106
+ const { file } = await browser.download('#export', { filename: 'report.csv' })
107
+ await writeFile('report.csv', file.bytes)
108
+ ```
109
+
110
+ When no output path is supplied, other file-producing commands collect their
111
+ artifact in `result.file`. This includes screenshots, PDFs, traces, profiles,
112
+ HAR files, saved browser state, and recordings:
113
+
114
+ ```ts
115
+ await browser.exec('network', { args: ['har', 'start'] })
116
+ // ...interact with the page...
117
+ const result = await browser.exec('network', { args: ['har', 'stop'] })
118
+ await writeFile('capture.har', result.file.bytes)
119
+ ```
120
+
80
121
  ### Convenience methods
81
122
 
82
- - `browser.snapshot(url)` opens a page and returns its interactive snapshot.
83
- - `browser.screenshot(url, { fullPage: true })` returns a PNG buffer.
123
+ - `browser.snapshot(url?)` optionally opens a page, then returns its interactive
124
+ snapshot. Omit the URL to inspect the current page.
125
+ - `browser.screenshot(url?, { fullPage: true })` optionally opens a page, then
126
+ returns a PNG buffer. Pass the options object first to capture the current page.
84
127
  - `browser.close()` closes the session and stops the Sandbox.
85
128
 
86
129
  All methods use the same disposable browser session until `close()` is called.
package/dist/index.d.ts CHANGED
@@ -4,11 +4,16 @@
4
4
  * The library has two layers:
5
5
  * - `AgentBrowser` (browser.ts): a thin, session-oriented client over the
6
6
  * `agent-browser` CLI running somewhere that can execute commands.
7
- * - `createAgentBrowser` (create.ts): provisions a Vercel Sandbox from a
7
+ * - `AgentBrowser.create()` (create.ts): provisions a Vercel Sandbox from a
8
8
  * browser-ready image and returns an `AgentBrowser` backed by it.
9
9
  */
10
10
  /** One agent-browser CLI invocation result. */
11
- type BrowserCommandResult = {
11
+ type BrowserFile = {
12
+ path: string;
13
+ bytes: Buffer;
14
+ contentType: string;
15
+ };
16
+ type BrowserCommandResult<T = never> = {
12
17
  /** The CLI args that ran, e.g. ["snapshot", "-i", "--json"] */
13
18
  args: string[];
14
19
  ok: boolean;
@@ -16,11 +21,14 @@ type BrowserCommandResult = {
16
21
  stdout: string;
17
22
  stderr: string;
18
23
  /** File bytes collected from the sandbox for file-producing commands. */
19
- file?: {
20
- path: string;
21
- bytes: Buffer;
22
- contentType: string;
23
- };
24
+ file?: BrowserFile;
25
+ } & ([T] extends [never] ? {} : {
26
+ data: T;
27
+ });
28
+ /** A local file to make available to an upload command. */
29
+ type BrowserUploadFile = {
30
+ name: string;
31
+ bytes: Buffer;
24
32
  };
25
33
  /** A batch of commands run sequentially in one session. */
26
34
  type BrowserRunResult = {
@@ -36,6 +44,9 @@ type RunOptions = {
36
44
  /** Per-command timeout in ms. */
37
45
  timeoutMs?: number;
38
46
  };
47
+ type ScreenshotOptions = RunOptions & {
48
+ fullPage?: boolean;
49
+ };
39
50
  type ExecOptions = {
40
51
  session?: string;
41
52
  /** Positional CLI args, e.g. ["@e3"] for click. */
@@ -43,6 +54,18 @@ type ExecOptions = {
43
54
  /** Flags serialized to --kebab-case (true → bare flag, arrays → repeated). */
44
55
  flags?: Record<string, unknown>;
45
56
  timeoutMs?: number;
57
+ /** Leave command output as text. This is the default. */
58
+ output?: 'text';
59
+ };
60
+ type JsonExecOptions = Omit<ExecOptions, 'output'> & {
61
+ output: 'json';
62
+ };
63
+ type BrowserClientOptions = {
64
+ session?: string;
65
+ /** agent-browser CLI arguments inserted before every command. */
66
+ args?: string[];
67
+ /** Whether close() also tears down the command runner. Default: true. */
68
+ ownsRunner?: boolean;
46
69
  };
47
70
  /**
48
71
  * Minimal command executor the browser client needs. Implemented by the
@@ -61,23 +84,34 @@ interface CommandRunner {
61
84
  }>;
62
85
  /** Read a file's bytes from the remote environment. */
63
86
  readFile(path: string): Promise<Buffer>;
87
+ /** Write file bytes into the remote environment. */
88
+ writeFile(path: string, bytes: Buffer): Promise<void>;
64
89
  /** Tear the remote environment down. */
65
90
  close(): Promise<void>;
66
91
  }
67
92
  /** Session-oriented remote browser client. */
68
- interface AgentBrowser {
93
+ interface AgentBrowser$1 {
69
94
  /** The runner's session name this client uses by default. */
70
95
  readonly session: string;
71
96
  /** Run a batch of agent-browser arg-arrays sequentially. */
72
97
  run(commands: string[][], opts?: RunOptions): Promise<BrowserRunResult>;
73
- /** Run one agent-browser command by name. */
98
+ /** Run one command and unwrap its typed --json data payload. */
99
+ exec<T>(command: string, opts: JsonExecOptions): Promise<BrowserCommandResult<T>>;
100
+ /** Run one agent-browser command with text output. */
74
101
  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;
102
+ /** Upload one or more local buffers through a file input. */
103
+ upload(selector: string, files: BrowserUploadFile[], opts?: Pick<ExecOptions, 'session' | 'timeoutMs'>): Promise<BrowserCommandResult>;
104
+ /** Download a file triggered by an element and return its bytes. */
105
+ download(selector: string, opts?: Pick<ExecOptions, 'session' | 'timeoutMs'> & {
106
+ filename?: string;
80
107
  }): Promise<{
108
+ file: BrowserFile;
109
+ result: BrowserCommandResult;
110
+ }>;
111
+ /** Optionally open a URL, then return the interactive accessibility snapshot. */
112
+ snapshot(urlOrOptions?: string | RunOptions, opts?: RunOptions): Promise<BrowserRunResult>;
113
+ /** Optionally open a URL, then return a PNG screenshot. */
114
+ screenshot(urlOrOptions?: string | ScreenshotOptions, opts?: ScreenshotOptions): Promise<{
81
115
  png: Buffer;
82
116
  result: BrowserRunResult;
83
117
  }>;
@@ -99,6 +133,8 @@ type CreateAgentBrowserOptions = {
99
133
  vcpus?: number;
100
134
  /** Extra env vars for every command inside the sandbox. */
101
135
  env?: Record<string, string>;
136
+ /** agent-browser CLI arguments inserted before every command. */
137
+ args?: string[];
102
138
  };
103
139
  /**
104
140
  * Create an agent-browser client running inside a fresh Vercel Sandbox.
@@ -112,9 +148,9 @@ type CreateAgentBrowserOptions = {
112
148
  * `vercel login`), via @vercel/sandbox.
113
149
  *
114
150
  * ```ts
115
- * import { createAgentBrowser } from 'remote-agent-browser'
151
+ * import { AgentBrowser } from 'remote-agent-browser'
116
152
  *
117
- * const browser = await createAgentBrowser()
153
+ * const browser = await AgentBrowser.create()
118
154
  * const { results } = await browser.run([
119
155
  * ['open', 'https://example.com'],
120
156
  * ['snapshot', '-i', '--json'],
@@ -122,7 +158,7 @@ type CreateAgentBrowserOptions = {
122
158
  * await browser.close()
123
159
  * ```
124
160
  */
125
- declare function createAgentBrowser(opts?: CreateAgentBrowserOptions): Promise<AgentBrowser>;
161
+ declare function createAgentBrowser(opts?: CreateAgentBrowserOptions): Promise<AgentBrowser$1>;
126
162
 
127
163
  /**
128
164
  * Create a session-oriented browser client over any CommandRunner.
@@ -132,10 +168,13 @@ declare function createAgentBrowser(opts?: CreateAgentBrowserOptions): Promise<A
132
168
  * explicit session name to share state across clients, or let it default to
133
169
  * an ephemeral per-client session that is closed on `close()`.
134
170
  */
135
- declare function createBrowserClient(runner: CommandRunner, opts?: {
136
- session?: string;
137
- ownsRunner?: boolean;
138
- }): AgentBrowser;
171
+ declare function createBrowserClient(runner: CommandRunner, opts?: BrowserClientOptions): AgentBrowser$1;
172
+
173
+ /** Create AgentBrowser clients backed by a fresh Vercel Sandbox. */
174
+ declare const AgentBrowser: {
175
+ create: typeof createAgentBrowser;
176
+ };
177
+ type AgentBrowser = AgentBrowser$1;
139
178
 
140
- export { createAgentBrowser, createBrowserClient };
141
- export type { AgentBrowser, BrowserCommandResult, BrowserRunResult, CommandRunner, CreateAgentBrowserOptions, ExecOptions, RunOptions };
179
+ export { AgentBrowser, createAgentBrowser, createBrowserClient };
180
+ export type { BrowserClientOptions, BrowserCommandResult, BrowserRunResult, CommandRunner, CreateAgentBrowserOptions, ExecOptions, JsonExecOptions, RunOptions };
package/dist/index.js CHANGED
@@ -1,34 +1,21 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { Sandbox } from '@vercel/sandbox';
3
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
- };
4
+ const FLAGS_WITH_VALUE = new Set([
5
+ '--screenshot-dir',
6
+ '--screenshot-quality',
7
+ '--screenshot-format',
8
+ '--format',
9
+ '--width',
10
+ '--height',
11
+ '--margin',
12
+ '--scale'
13
+ ]);
31
14
  const SESSION_NAME = /^[\w.-]{1,64}$/;
15
+ function safeFilename(name) {
16
+ const filename = name.split(/[\\/]/).at(-1)?.replace(/[^\w.-]/g, '-');
17
+ return filename || 'file';
18
+ }
32
19
  /** { json: true, fullPage: true, depth: 2 } → ["--json", "--full-page", "--depth", "2"] */ function flagsToArgs(flags) {
33
20
  const args = [];
34
21
  for (const [key, value] of Object.entries(flags)){
@@ -45,13 +32,70 @@ const SESSION_NAME = /^[\w.-]{1,64}$/;
45
32
  }
46
33
  return args;
47
34
  }
48
- function withSession(session, args) {
35
+ function withSession(session, clientArgs, commandArgs) {
49
36
  return [
50
37
  '--session',
51
38
  session,
52
- ...args
39
+ ...clientArgs,
40
+ ...commandArgs
53
41
  ];
54
42
  }
43
+ function positionalArgs(args) {
44
+ const positionals = [];
45
+ for(let index = 0; index < args.length; index++){
46
+ const arg = args[index];
47
+ if (!arg.startsWith('-')) {
48
+ positionals.push(arg);
49
+ } else if (!arg.includes('=') && FLAGS_WITH_VALUE.has(arg)) {
50
+ index++;
51
+ }
52
+ }
53
+ return positionals;
54
+ }
55
+ function flagValue(args, flag) {
56
+ const prefixed = args.find((arg)=>arg.startsWith(`${flag}=`));
57
+ if (prefixed) return prefixed.slice(flag.length + 1);
58
+ const index = args.lastIndexOf(flag);
59
+ return index >= 0 ? args[index + 1] : undefined;
60
+ }
61
+ function outputSpec(args) {
62
+ const [command, ...rest] = args;
63
+ const positionals = positionalArgs(rest);
64
+ if (command === 'screenshot' && positionals.length === 0) {
65
+ const format = flagValue(rest, '--screenshot-format')?.toLowerCase();
66
+ return format === 'jpeg' ? {
67
+ ext: 'jpeg',
68
+ contentType: 'image/jpeg'
69
+ } : {
70
+ ext: 'png',
71
+ contentType: 'image/png'
72
+ };
73
+ }
74
+ if (command === 'pdf' && positionals.length === 0) {
75
+ return {
76
+ ext: 'pdf',
77
+ contentType: 'application/pdf'
78
+ };
79
+ }
80
+ if ((command === 'trace' || command === 'profiler') && positionals.length === 1 && positionals[0] === 'stop') {
81
+ return {
82
+ ext: 'json',
83
+ contentType: 'application/json'
84
+ };
85
+ }
86
+ if (command === 'network' && positionals.length === 2 && positionals[0] === 'har' && positionals[1] === 'stop') {
87
+ return {
88
+ ext: 'har',
89
+ contentType: 'application/json'
90
+ };
91
+ }
92
+ if (command === 'state' && positionals.length === 1 && positionals[0] === 'save') {
93
+ return {
94
+ ext: 'json',
95
+ contentType: 'application/json'
96
+ };
97
+ }
98
+ }
55
99
  /**
56
100
  * Create a session-oriented browser client over any CommandRunner.
57
101
  *
@@ -64,28 +108,47 @@ function withSession(session, args) {
64
108
  if (!SESSION_NAME.test(session)) {
65
109
  throw new Error(`session must match [A-Za-z0-9_.-]{1,64}, got "${session}"`);
66
110
  }
111
+ const clientArgs = [
112
+ ...opts.args ?? []
113
+ ];
67
114
  const ownsRunner = opts.ownsRunner ?? true;
115
+ const recordings = new Map();
68
116
  let closed = false;
69
117
  function assertOpen() {
70
118
  if (closed) throw new Error('AgentBrowser is closed');
71
119
  }
72
120
  async function execCommand(args, timeoutMs, useSession = session) {
73
121
  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);
122
+ // File-producing commands get a temporary remote output path when the
123
+ // caller did not provide one. The bytes are pulled back after completion.
124
+ let fileSpec = outputSpec(args);
125
+ const recordAction = name === 'record' ? positionalArgs(rest) : [];
126
+ const startsRecording = recordAction.length === 1 && (recordAction[0] === 'start' || recordAction[0] === 'restart');
127
+ const stopsRecording = recordAction.length === 1 && recordAction[0] === 'stop';
128
+ let finalArgs = withSession(useSession, clientArgs, args);
80
129
  let remotePath;
81
- if (wantsFile) {
130
+ if (fileSpec) {
82
131
  remotePath = `/tmp/agent-browser-${randomUUID()}.${fileSpec.ext}`;
83
- // screenshot <path> is the first positional for these commands
84
- finalArgs = withSession(useSession, [
132
+ finalArgs = withSession(useSession, clientArgs, [
85
133
  name,
86
134
  ...rest,
87
135
  remotePath
88
136
  ]);
137
+ } else if (startsRecording) {
138
+ fileSpec = {
139
+ ext: 'webm',
140
+ contentType: 'video/webm'
141
+ };
142
+ remotePath = `/tmp/agent-browser-${randomUUID()}.webm`;
143
+ finalArgs = withSession(useSession, clientArgs, [
144
+ name,
145
+ recordAction[0],
146
+ remotePath
147
+ ]);
148
+ } else if (stopsRecording) {
149
+ const recording = recordings.get(useSession);
150
+ remotePath = recording?.path;
151
+ fileSpec = recording?.spec;
89
152
  }
90
153
  const run = await runner.run('agent-browser', finalArgs, {
91
154
  timeoutMs
@@ -97,7 +160,12 @@ function withSession(session, args) {
97
160
  stdout: run.stdout,
98
161
  stderr: run.stderr
99
162
  };
100
- if (result.ok && remotePath && fileSpec) {
163
+ if (result.ok && startsRecording && remotePath && fileSpec) {
164
+ recordings.set(useSession, {
165
+ path: remotePath,
166
+ spec: fileSpec
167
+ });
168
+ } else if (result.ok && remotePath && fileSpec) {
101
169
  try {
102
170
  result.file = {
103
171
  path: remotePath,
@@ -109,8 +177,45 @@ function withSession(session, args) {
109
177
  result.stderr += `\ncommand succeeded but ${remotePath} could not be read: ${error instanceof Error ? error.message : String(error)}`;
110
178
  }
111
179
  }
180
+ if (stopsRecording) recordings.delete(useSession);
112
181
  return result;
113
182
  }
183
+ async function exec(command, execOpts = {}) {
184
+ assertOpen();
185
+ const jsonOutput = execOpts.output === 'json';
186
+ const flags = jsonOutput ? {
187
+ ...execOpts.flags,
188
+ json: true
189
+ } : execOpts.flags;
190
+ const args = [
191
+ command,
192
+ ...execOpts.args ?? [],
193
+ ...flags ? flagsToArgs(flags) : []
194
+ ];
195
+ const useSession = execOpts.session ?? session;
196
+ if (!SESSION_NAME.test(useSession)) {
197
+ throw new Error(`session must match [A-Za-z0-9_.-]{1,64}, got "${useSession}"`);
198
+ }
199
+ const result = await execCommand(args, execOpts.timeoutMs, useSession);
200
+ if (!jsonOutput) return result;
201
+ if (!result.ok) {
202
+ const details = result.stderr.trim() || result.stdout.trim() || 'unknown error';
203
+ throw new Error(`agent-browser ${command} failed with exit code ${result.exitCode}: ${details}`);
204
+ }
205
+ let envelope;
206
+ try {
207
+ envelope = JSON.parse(result.stdout);
208
+ } catch (error) {
209
+ throw new SyntaxError(`agent-browser ${command} did not return valid JSON: ${error instanceof Error ? error.message : String(error)}`);
210
+ }
211
+ if (typeof envelope !== 'object' || envelope === null || !('success' in envelope) || envelope.success !== true || !('data' in envelope)) {
212
+ throw new SyntaxError(`agent-browser ${command} did not return a successful JSON envelope`);
213
+ }
214
+ return {
215
+ ...result,
216
+ data: envelope.data
217
+ };
218
+ }
114
219
  return {
115
220
  session,
116
221
  async run (commands, runOpts = {}) {
@@ -144,44 +249,81 @@ function withSession(session, args) {
144
249
  ok: results.every((r)=>r.ok)
145
250
  };
146
251
  },
147
- async exec (command, execOpts = {}) {
252
+ exec,
253
+ async upload (selector, files, execOpts = {}) {
148
254
  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}"`);
255
+ if (files.length === 0) throw new Error('upload requires at least one file');
256
+ const remotePaths = [];
257
+ for (const file of files){
258
+ const remotePath = `/tmp/agent-browser-${randomUUID()}-${safeFilename(file.name)}`;
259
+ await runner.writeFile(remotePath, file.bytes);
260
+ remotePaths.push(remotePath);
157
261
  }
158
- return execCommand(args, execOpts.timeoutMs, useSession);
262
+ return this.exec('upload', {
263
+ ...execOpts,
264
+ args: [
265
+ selector,
266
+ ...remotePaths
267
+ ]
268
+ });
159
269
  },
160
- async snapshot (url, runOpts = {}) {
161
- return this.run([
270
+ async download (selector, execOpts = {}) {
271
+ assertOpen();
272
+ const remotePath = `/tmp/agent-browser-${randomUUID()}-${safeFilename(execOpts.filename ?? 'download')}`;
273
+ const result = await this.exec('download', {
274
+ session: execOpts.session,
275
+ timeoutMs: execOpts.timeoutMs,
276
+ args: [
277
+ selector,
278
+ remotePath
279
+ ]
280
+ });
281
+ if (!result.ok) throw new Error(`download failed: ${result.stderr || result.stdout}`);
282
+ try {
283
+ result.file = {
284
+ path: remotePath,
285
+ bytes: await runner.readFile(remotePath),
286
+ contentType: 'application/octet-stream'
287
+ };
288
+ } catch (error) {
289
+ throw new Error(`download succeeded but ${remotePath} could not be read: ${error instanceof Error ? error.message : String(error)}`);
290
+ }
291
+ return {
292
+ file: result.file,
293
+ result
294
+ };
295
+ },
296
+ async snapshot (urlOrOptions, opts = {}) {
297
+ const url = typeof urlOrOptions === 'string' ? urlOrOptions : undefined;
298
+ const runOpts = typeof urlOrOptions === 'string' ? opts : urlOrOptions ?? {};
299
+ const commands = url ? [
162
300
  [
163
301
  'open',
164
302
  url
165
- ],
166
- [
167
- 'snapshot',
168
- '-i',
169
- '--json'
170
303
  ]
171
- ], runOpts);
304
+ ] : [];
305
+ commands.push([
306
+ 'snapshot',
307
+ '-i',
308
+ '--json'
309
+ ]);
310
+ return this.run(commands, runOpts);
172
311
  },
173
- async screenshot (url, runOpts = {}) {
312
+ async screenshot (urlOrOptions, opts = {}) {
313
+ const url = typeof urlOrOptions === 'string' ? urlOrOptions : undefined;
314
+ const runOpts = typeof urlOrOptions === 'string' ? opts : urlOrOptions ?? {};
174
315
  const shotArgs = [
175
316
  'screenshot'
176
317
  ];
177
318
  if (runOpts.fullPage) shotArgs.push('--full');
178
- const run = await this.run([
319
+ const commands = url ? [
179
320
  [
180
321
  'open',
181
322
  url
182
- ],
183
- shotArgs
184
- ], runOpts);
323
+ ]
324
+ ] : [];
325
+ commands.push(shotArgs);
326
+ const run = await this.run(commands, runOpts);
185
327
  const file = run.results.find((r)=>r.file)?.file;
186
328
  if (!file) {
187
329
  throw new Error(`screenshot failed: ${run.results.map((r)=>r.stderr || r.stdout).join('\n') || 'no output'}`);
@@ -195,7 +337,7 @@ function withSession(session, args) {
195
337
  if (closed) return;
196
338
  closed = true;
197
339
  // Close the CLI session's browser first so the sandbox can stop cleanly.
198
- await runner.run('agent-browser', withSession(session, [
340
+ await runner.run('agent-browser', withSession(session, clientArgs, [
199
341
  'close'
200
342
  ]), {
201
343
  timeoutMs: 15_000
@@ -266,6 +408,14 @@ const BASE_ENV = {
266
408
  }
267
409
  return Buffer.from(stdout.trim(), 'base64');
268
410
  }
411
+ async writeFile(path, bytes) {
412
+ await this.sandbox.writeFiles([
413
+ {
414
+ path,
415
+ content: bytes
416
+ }
417
+ ]);
418
+ }
269
419
  async close() {
270
420
  await this.sandbox.stop().catch(()=>{});
271
421
  }
@@ -307,9 +457,9 @@ const BASE_ENV = {
307
457
  * `vercel login`), via @vercel/sandbox.
308
458
  *
309
459
  * ```ts
310
- * import { createAgentBrowser } from 'remote-agent-browser'
460
+ * import { AgentBrowser } from 'remote-agent-browser'
311
461
  *
312
- * const browser = await createAgentBrowser()
462
+ * const browser = await AgentBrowser.create()
313
463
  * const { results } = await browser.run([
314
464
  * ['open', 'https://example.com'],
315
465
  * ['snapshot', '-i', '--json'],
@@ -325,8 +475,15 @@ const BASE_ENV = {
325
475
  });
326
476
  return createBrowserClient(runner, {
327
477
  session: opts.session,
478
+ args: opts.args,
328
479
  ownsRunner: true
329
480
  });
330
481
  }
331
482
 
332
- export { createAgentBrowser, createBrowserClient };
483
+ // Public library surface: create an agent browser in a Vercel Sandbox and
484
+ // drive it with the agent-browser CLI.
485
+ /** Create AgentBrowser clients backed by a fresh Vercel Sandbox. */ const AgentBrowser = {
486
+ create: createAgentBrowser
487
+ };
488
+
489
+ export { AgentBrowser, createAgentBrowser, createBrowserClient };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-agent-browser",
3
- "version": "0.1.0",
3
+ "version": "0.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": {
@@ -27,8 +27,7 @@
27
27
  "test:unit": "node --run build && node --test tests/unit.test.mjs"
28
28
  },
29
29
  "files": [
30
- "dist",
31
- "Dockerfile.sandbox"
30
+ "dist"
32
31
  ],
33
32
  "dependencies": {
34
33
  "@vercel/sandbox": "^2.9.0"
@@ -1,19 +0,0 @@
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"]