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 +65 -22
- package/dist/index.d.ts +62 -23
- package/dist/index.js +223 -66
- package/package.json +2 -3
- package/Dockerfile.sandbox +0 -19
package/README.md
CHANGED
|
@@ -12,27 +12,17 @@ pnpm add remote-agent-browser
|
|
|
12
12
|
|
|
13
13
|
## Use
|
|
14
14
|
|
|
15
|
-
|
|
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
|
|
36
|
+
node --env-file=.env.local examples/screenshot/index.mjs
|
|
47
37
|
```
|
|
48
38
|
|
|
49
39
|
## API
|
|
50
40
|
|
|
51
|
-
### `
|
|
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
|
|
83
|
-
|
|
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
|
-
* - `
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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
|
-
/**
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
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 {
|
|
151
|
+
* import { AgentBrowser } from 'remote-agent-browser'
|
|
116
152
|
*
|
|
117
|
-
* const browser = await
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 {
|
|
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
|
-
|
|
5
|
-
screenshot
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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,
|
|
35
|
+
function withSession(session, clientArgs, commandArgs) {
|
|
49
36
|
return [
|
|
50
37
|
'--session',
|
|
51
38
|
session,
|
|
52
|
-
...
|
|
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
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
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 (
|
|
130
|
+
if (fileSpec) {
|
|
82
131
|
remotePath = `/tmp/agent-browser-${randomUUID()}.${fileSpec.ext}`;
|
|
83
|
-
|
|
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
|
-
|
|
252
|
+
exec,
|
|
253
|
+
async upload (selector, files, execOpts = {}) {
|
|
148
254
|
assertOpen();
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
262
|
+
return this.exec('upload', {
|
|
263
|
+
...execOpts,
|
|
264
|
+
args: [
|
|
265
|
+
selector,
|
|
266
|
+
...remotePaths
|
|
267
|
+
]
|
|
268
|
+
});
|
|
159
269
|
},
|
|
160
|
-
async
|
|
161
|
-
|
|
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
|
-
]
|
|
304
|
+
] : [];
|
|
305
|
+
commands.push([
|
|
306
|
+
'snapshot',
|
|
307
|
+
'-i',
|
|
308
|
+
'--json'
|
|
309
|
+
]);
|
|
310
|
+
return this.run(commands, runOpts);
|
|
172
311
|
},
|
|
173
|
-
async screenshot (
|
|
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
|
|
319
|
+
const commands = url ? [
|
|
179
320
|
[
|
|
180
321
|
'open',
|
|
181
322
|
url
|
|
182
|
-
]
|
|
183
|
-
|
|
184
|
-
|
|
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 {
|
|
460
|
+
* import { AgentBrowser } from 'remote-agent-browser'
|
|
311
461
|
*
|
|
312
|
-
* const browser = await
|
|
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
|
-
|
|
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.
|
|
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"
|
package/Dockerfile.sandbox
DELETED
|
@@ -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"]
|