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.
- package/Dockerfile.sandbox +19 -0
- package/README.md +88 -0
- package/dist/index.d.ts +141 -0
- package/dist/index.js +332 -0
- package/package.json +41 -0
|
@@ -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).
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|