remote-agent-browser 1.0.0 → 1.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 +114 -4
- package/dist/index.d.ts +74 -2
- package/dist/index.js +350 -17
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# remote-agent-browser
|
|
2
2
|
|
|
3
|
-
Run [agent-browser](https://github.com/vercel-labs/agent-browser) in
|
|
4
|
-
[Vercel Sandbox](https://vercel.com/docs/vercel-sandbox).
|
|
5
|
-
come preinstalled, so there is no browser or Docker setup at runtime.
|
|
3
|
+
Run [agent-browser](https://github.com/vercel-labs/agent-browser) in the cloud
|
|
4
|
+
with an isolated [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox).
|
|
6
5
|
|
|
7
6
|
## Install
|
|
8
7
|
|
|
@@ -22,6 +21,10 @@ references. `close()` closes Chromium and stops the Sandbox.
|
|
|
22
21
|
PNG and save it locally.
|
|
23
22
|
- [Agent Bash tool integration](./examples/agent-bash-tool/README.md) — let an
|
|
24
23
|
agent use normal `agent-browser <command>` invocations through its Bash tool.
|
|
24
|
+
- [Bypass BotID for trusted automation](./examples/botid-bypass/README.md) —
|
|
25
|
+
pass a Vercel automation bypass token as an origin-scoped header.
|
|
26
|
+
- [Route traffic through a proxy](./examples/proxy/README.md) — configure an
|
|
27
|
+
authenticated proxy and verify the browser's egress address.
|
|
25
28
|
|
|
26
29
|
## Authentication
|
|
27
30
|
|
|
@@ -45,7 +48,7 @@ Starts a fresh browser in a disposable Vercel Sandbox. Always call
|
|
|
45
48
|
|
|
46
49
|
Pass agent-browser global options with `args`. They are placed before
|
|
47
50
|
every command, which is required for launch settings such as color scheme,
|
|
48
|
-
profiles,
|
|
51
|
+
profiles, and init scripts:
|
|
49
52
|
|
|
50
53
|
```ts
|
|
51
54
|
const browser = await AgentBrowser.create({
|
|
@@ -53,6 +56,28 @@ const browser = await AgentBrowser.create({
|
|
|
53
56
|
})
|
|
54
57
|
```
|
|
55
58
|
|
|
59
|
+
### Proxy
|
|
60
|
+
|
|
61
|
+
Set a proxy for the browser client's lifetime with `proxy`. Use the object form
|
|
62
|
+
to configure hosts that should connect directly:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const browser = await AgentBrowser.create({
|
|
66
|
+
proxy: {
|
|
67
|
+
url: 'http://user:password@proxy.example.com:8080',
|
|
68
|
+
bypass: ['localhost', '*.internal.example.com'],
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
For a proxy without bypass rules, pass its URL directly:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const browser = await AgentBrowser.create({
|
|
77
|
+
proxy: 'http://proxy.example.com:8080',
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
56
81
|
### `browser.run(commands)`
|
|
57
82
|
|
|
58
83
|
Run several agent-browser commands in the same session:
|
|
@@ -77,6 +102,25 @@ await browser.exec('find', {
|
|
|
77
102
|
})
|
|
78
103
|
```
|
|
79
104
|
|
|
105
|
+
### `browser.shell(command, options?)`
|
|
106
|
+
|
|
107
|
+
Use this when forwarding a Bash-tool command or composing `agent-browser` with
|
|
108
|
+
normal shell utilities. The command runs verbatim, so quoting, pipes,
|
|
109
|
+
redirection, and control operators keep their shell semantics. Prefer `exec()`
|
|
110
|
+
or `run()` for browser commands that do not need a shell. Every `agent-browser`
|
|
111
|
+
invocation inherits the client's CLI session through `AGENT_BROWSER_SESSION`.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
const result = await browser.shell(
|
|
115
|
+
'agent-browser read "https://example.com" | grep -io "example" | wc -l',
|
|
116
|
+
)
|
|
117
|
+
console.log(result.stdout)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Client-wide `args` are not inserted into a shell string because doing so would
|
|
121
|
+
require rewriting arbitrary shell syntax. Put global CLI arguments directly in
|
|
122
|
+
the command when using `shell()`.
|
|
123
|
+
|
|
80
124
|
### Typed JSON
|
|
81
125
|
|
|
82
126
|
Set `output: 'json'` for commands that support `--json`. `exec<T>()` adds the
|
|
@@ -118,6 +162,17 @@ const result = await browser.exec('network', { args: ['har', 'stop'] })
|
|
|
118
162
|
await writeFile('capture.har', result.file.bytes)
|
|
119
163
|
```
|
|
120
164
|
|
|
165
|
+
When a command already contains an explicit remote output path, read it back
|
|
166
|
+
without changing the command:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
await browser.shell(
|
|
170
|
+
'agent-browser screenshot /tmp/verification.png',
|
|
171
|
+
)
|
|
172
|
+
const file = await browser.readFile('/tmp/verification.png', 'image/png')
|
|
173
|
+
await writeFile('verification.png', file.bytes)
|
|
174
|
+
```
|
|
175
|
+
|
|
121
176
|
### Convenience methods
|
|
122
177
|
|
|
123
178
|
- `browser.snapshot(url?)` optionally opens a page, then returns its interactive
|
|
@@ -128,4 +183,59 @@ await writeFile('capture.har', result.file.bytes)
|
|
|
128
183
|
|
|
129
184
|
All methods use the same disposable browser session until `close()` is called.
|
|
130
185
|
|
|
186
|
+
### Stable browser ids
|
|
187
|
+
|
|
188
|
+
Use `AgentBrowser.session()` when browser identity must survive process
|
|
189
|
+
boundaries. It returns a lazy handle: constructing it or starting keepalive
|
|
190
|
+
does not create a Sandbox. The first browser command finds or creates a runtime
|
|
191
|
+
derived from the caller-defined id, and another process using the same id finds
|
|
192
|
+
that runtime again.
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
const browser = AgentBrowser.session({ id: `environment:${chatId}` })
|
|
196
|
+
const stopKeepalive = browser.keepalive()
|
|
197
|
+
try {
|
|
198
|
+
await browser.exec('open', { args: ['https://example.com'] })
|
|
199
|
+
} finally {
|
|
200
|
+
stopKeepalive()
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Ids are project-scoped. Include the deployment environment or another namespace
|
|
205
|
+
when the same application uses one Vercel project for multiple environments.
|
|
206
|
+
The underlying Sandbox name is private and derived from a hash of the id.
|
|
207
|
+
|
|
208
|
+
If an expired Sandbox resumes, its Chromium process starts fresh. Subscribe to
|
|
209
|
+
`reset` before running commands when the caller needs to surface that page,
|
|
210
|
+
cookies, refs, tabs, and console history were lost:
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
browser.on('reset', ({ reason }) => {
|
|
214
|
+
console.log(`Browser runtime reset: ${reason}`)
|
|
215
|
+
})
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`browser.destroy()` permanently removes the named runtime. Stopping keepalive
|
|
219
|
+
only allows its normal idle timeout to resume.
|
|
220
|
+
|
|
221
|
+
### Keepalive
|
|
222
|
+
|
|
223
|
+
Keep the Sandbox alive across idle gaps during long-running work. Always stop
|
|
224
|
+
the heartbeat in a `finally` block; after it stops, the Sandbox expires at its
|
|
225
|
+
normal timeout unless it is closed earlier:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
const stopKeepalive = browser.keepalive()
|
|
229
|
+
try {
|
|
230
|
+
await runLongAgentTurn(browser)
|
|
231
|
+
} finally {
|
|
232
|
+
stopKeepalive()
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
By default, each heartbeat restores the wall-clock timeout configured by
|
|
237
|
+
`AgentBrowser.create()` and runs halfway through that window, capped at five
|
|
238
|
+
minutes. Override either value with `timeoutMs` and `intervalMs`. Renewal is
|
|
239
|
+
best-effort; pass `onError` to observe failures.
|
|
240
|
+
|
|
131
241
|
Container image and development details are in [docs.md](./docs.md).
|
package/dist/index.d.ts
CHANGED
|
@@ -36,6 +36,18 @@ type BrowserRunResult = {
|
|
|
36
36
|
results: BrowserCommandResult[];
|
|
37
37
|
ok: boolean;
|
|
38
38
|
};
|
|
39
|
+
/** Result from a verbatim shell command running in the browser environment. */
|
|
40
|
+
type BrowserShellResult = {
|
|
41
|
+
ok: boolean;
|
|
42
|
+
exitCode: number | null;
|
|
43
|
+
stdout: string;
|
|
44
|
+
stderr: string;
|
|
45
|
+
};
|
|
46
|
+
type ShellOptions = {
|
|
47
|
+
/** Named agent-browser CLI session available to commands through the environment. */
|
|
48
|
+
session?: string;
|
|
49
|
+
timeoutMs?: number;
|
|
50
|
+
};
|
|
39
51
|
type RunOptions = {
|
|
40
52
|
/** Named session to reuse browser state across calls. Default: ephemeral. */
|
|
41
53
|
session?: string;
|
|
@@ -60,13 +72,37 @@ type ExecOptions = {
|
|
|
60
72
|
type JsonExecOptions = Omit<ExecOptions, 'output'> & {
|
|
61
73
|
output: 'json';
|
|
62
74
|
};
|
|
75
|
+
type ProxyOptions = string | {
|
|
76
|
+
/** Proxy server URL, optionally including authentication. */
|
|
77
|
+
url: string;
|
|
78
|
+
/** Host patterns that should connect directly instead of using the proxy. */
|
|
79
|
+
bypass?: string | readonly string[];
|
|
80
|
+
};
|
|
63
81
|
type BrowserClientOptions = {
|
|
64
82
|
session?: string;
|
|
65
83
|
/** agent-browser CLI arguments inserted before every command. */
|
|
66
84
|
args?: string[];
|
|
85
|
+
/** Proxy configuration fixed for this client's lifetime. */
|
|
86
|
+
proxy?: ProxyOptions;
|
|
87
|
+
/** Run `agent-browser close` before closing the runner. Default: true. */
|
|
88
|
+
closeSession?: boolean;
|
|
67
89
|
/** Whether close() also tears down the command runner. Default: true. */
|
|
68
90
|
ownsRunner?: boolean;
|
|
69
91
|
};
|
|
92
|
+
type KeepaliveOptions = {
|
|
93
|
+
/** Remaining sandbox lifetime to maintain. Defaults to its creation timeout. */
|
|
94
|
+
timeoutMs?: number;
|
|
95
|
+
/** Heartbeat cadence. Defaults to half the timeout, capped at 5 minutes. */
|
|
96
|
+
intervalMs?: number;
|
|
97
|
+
/** Optional observer for best-effort heartbeat failures. */
|
|
98
|
+
onError?: (error: unknown) => void;
|
|
99
|
+
};
|
|
100
|
+
type BrowserRuntimeResetEvent = {
|
|
101
|
+
/** Stable logical browser id supplied by the caller. */
|
|
102
|
+
id: string;
|
|
103
|
+
/** Why the backing runtime no longer has its previous in-memory state. */
|
|
104
|
+
reason: 'resumed';
|
|
105
|
+
};
|
|
70
106
|
/**
|
|
71
107
|
* Minimal command executor the browser client needs. Implemented by the
|
|
72
108
|
* Vercel Sandbox adapter; anything with the same shape can back a
|
|
@@ -88,6 +124,8 @@ interface CommandRunner {
|
|
|
88
124
|
writeFile(path: string, bytes: Buffer): Promise<void>;
|
|
89
125
|
/** Tear the remote environment down. */
|
|
90
126
|
close(): Promise<void>;
|
|
127
|
+
/** Keep the remote environment alive until the returned stop function runs. */
|
|
128
|
+
keepalive?(opts?: KeepaliveOptions): () => void;
|
|
91
129
|
}
|
|
92
130
|
/** Session-oriented remote browser client. */
|
|
93
131
|
interface AgentBrowser$1 {
|
|
@@ -95,6 +133,8 @@ interface AgentBrowser$1 {
|
|
|
95
133
|
readonly session: string;
|
|
96
134
|
/** Run a batch of agent-browser arg-arrays sequentially. */
|
|
97
135
|
run(commands: string[][], opts?: RunOptions): Promise<BrowserRunResult>;
|
|
136
|
+
/** Run a shell command verbatim, preserving quoting and control operators. */
|
|
137
|
+
shell(command: string, opts?: ShellOptions): Promise<BrowserShellResult>;
|
|
98
138
|
/** Run one command and unwrap its typed --json data payload. */
|
|
99
139
|
exec<T>(command: string, opts: JsonExecOptions): Promise<BrowserCommandResult<T>>;
|
|
100
140
|
/** Run one agent-browser command with text output. */
|
|
@@ -108,6 +148,8 @@ interface AgentBrowser$1 {
|
|
|
108
148
|
file: BrowserFile;
|
|
109
149
|
result: BrowserCommandResult;
|
|
110
150
|
}>;
|
|
151
|
+
/** Read an explicitly named artifact from the browser environment. */
|
|
152
|
+
readFile(path: string, contentType?: string): Promise<BrowserFile>;
|
|
111
153
|
/** Optionally open a URL, then return the interactive accessibility snapshot. */
|
|
112
154
|
snapshot(urlOrOptions?: string | RunOptions, opts?: RunOptions): Promise<BrowserRunResult>;
|
|
113
155
|
/** Optionally open a URL, then return a PNG screenshot. */
|
|
@@ -115,9 +157,23 @@ interface AgentBrowser$1 {
|
|
|
115
157
|
png: Buffer;
|
|
116
158
|
result: BrowserRunResult;
|
|
117
159
|
}>;
|
|
160
|
+
/**
|
|
161
|
+
* Keep the backing Sandbox alive across idle gaps. Call the returned stop
|
|
162
|
+
* function in a finally block so the Sandbox can expire when work finishes.
|
|
163
|
+
*/
|
|
164
|
+
keepalive(opts?: KeepaliveOptions): () => void;
|
|
118
165
|
/** Close the browser session (and the sandbox when it owns it). */
|
|
119
166
|
close(): Promise<void>;
|
|
120
167
|
}
|
|
168
|
+
/** A lazily acquired browser whose logical identity survives runtime expiry. */
|
|
169
|
+
interface AgentBrowserSession extends AgentBrowser$1 {
|
|
170
|
+
/** Stable caller-defined identity. The backing runtime may be replaced. */
|
|
171
|
+
readonly id: string;
|
|
172
|
+
/** Observe when an expired runtime resumes with fresh in-memory browser state. */
|
|
173
|
+
on(event: 'reset', listener: (event: BrowserRuntimeResetEvent) => void): () => void;
|
|
174
|
+
/** Permanently remove the runtime currently associated with this id. */
|
|
175
|
+
destroy(): Promise<void>;
|
|
176
|
+
}
|
|
121
177
|
|
|
122
178
|
type CreateAgentBrowserOptions = {
|
|
123
179
|
/** Named CLI session; state (page, cookies, refs) persists across calls. */
|
|
@@ -135,6 +191,12 @@ type CreateAgentBrowserOptions = {
|
|
|
135
191
|
env?: Record<string, string>;
|
|
136
192
|
/** agent-browser CLI arguments inserted before every command. */
|
|
137
193
|
args?: string[];
|
|
194
|
+
/** Proxy configuration fixed for this browser's lifetime. */
|
|
195
|
+
proxy?: ProxyOptions;
|
|
196
|
+
};
|
|
197
|
+
type CreateAgentBrowserSessionOptions = CreateAgentBrowserOptions & {
|
|
198
|
+
/** Stable logical identity used to find this browser across processes. */
|
|
199
|
+
id: string;
|
|
138
200
|
};
|
|
139
201
|
/**
|
|
140
202
|
* Create an agent-browser client running inside a fresh Vercel Sandbox.
|
|
@@ -159,6 +221,15 @@ type CreateAgentBrowserOptions = {
|
|
|
159
221
|
* ```
|
|
160
222
|
*/
|
|
161
223
|
declare function createAgentBrowser(opts?: CreateAgentBrowserOptions): Promise<AgentBrowser$1>;
|
|
224
|
+
/**
|
|
225
|
+
* Create a lazy browser handle with a stable logical id.
|
|
226
|
+
*
|
|
227
|
+
* The first operation finds or creates a named Vercel Sandbox. Later handles
|
|
228
|
+
* created with the same id attach to that Sandbox, including from another
|
|
229
|
+
* process. If an expired Sandbox resumes, listeners are told that its
|
|
230
|
+
* in-memory page, refs, cookies, and console history were reset.
|
|
231
|
+
*/
|
|
232
|
+
declare function createAgentBrowserSession(opts: CreateAgentBrowserSessionOptions): AgentBrowserSession;
|
|
162
233
|
|
|
163
234
|
/**
|
|
164
235
|
* Create a session-oriented browser client over any CommandRunner.
|
|
@@ -173,8 +244,9 @@ declare function createBrowserClient(runner: CommandRunner, opts?: BrowserClient
|
|
|
173
244
|
/** Create AgentBrowser clients backed by a fresh Vercel Sandbox. */
|
|
174
245
|
declare const AgentBrowser: {
|
|
175
246
|
create: typeof createAgentBrowser;
|
|
247
|
+
session: typeof createAgentBrowserSession;
|
|
176
248
|
};
|
|
177
249
|
type AgentBrowser = AgentBrowser$1;
|
|
178
250
|
|
|
179
|
-
export { AgentBrowser, createAgentBrowser, createBrowserClient };
|
|
180
|
-
export type { BrowserClientOptions, BrowserCommandResult, BrowserRunResult, CommandRunner, CreateAgentBrowserOptions, ExecOptions, JsonExecOptions, RunOptions };
|
|
251
|
+
export { AgentBrowser, createAgentBrowser, createAgentBrowserSession, createBrowserClient };
|
|
252
|
+
export type { AgentBrowserSession, BrowserClientOptions, BrowserCommandResult, BrowserFile, BrowserRunResult, BrowserRuntimeResetEvent, BrowserShellResult, CommandRunner, CreateAgentBrowserOptions, CreateAgentBrowserSessionOptions, ExecOptions, JsonExecOptions, KeepaliveOptions, ProxyOptions, RunOptions, ShellOptions };
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
1
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
2
2
|
import { Sandbox } from '@vercel/sandbox';
|
|
3
3
|
|
|
4
4
|
const FLAGS_WITH_VALUE = new Set([
|
|
@@ -12,6 +12,39 @@ const FLAGS_WITH_VALUE = new Set([
|
|
|
12
12
|
'--scale'
|
|
13
13
|
]);
|
|
14
14
|
const SESSION_NAME = /^[\w.-]{1,64}$/;
|
|
15
|
+
const PROXY_FLAGS = [
|
|
16
|
+
'--proxy',
|
|
17
|
+
'--proxy-bypass'
|
|
18
|
+
];
|
|
19
|
+
function proxyEnvironment(proxy) {
|
|
20
|
+
if (proxy === undefined) return undefined;
|
|
21
|
+
const url = typeof proxy === 'string' ? proxy : proxy.url;
|
|
22
|
+
const bypass = typeof proxy === 'string' ? undefined : proxy.bypass;
|
|
23
|
+
if (url.length === 0) {
|
|
24
|
+
throw new RangeError('proxy url must not be empty');
|
|
25
|
+
}
|
|
26
|
+
const env = {
|
|
27
|
+
AGENT_BROWSER_PROXY: url
|
|
28
|
+
};
|
|
29
|
+
if (bypass !== undefined) {
|
|
30
|
+
const serialized = typeof bypass === 'string' ? bypass : bypass.join(',');
|
|
31
|
+
if (serialized.length === 0) {
|
|
32
|
+
throw new RangeError('proxy bypass must not be empty');
|
|
33
|
+
}
|
|
34
|
+
env.AGENT_BROWSER_PROXY_BYPASS = serialized;
|
|
35
|
+
}
|
|
36
|
+
return env;
|
|
37
|
+
}
|
|
38
|
+
function hasProxyFlag(args) {
|
|
39
|
+
return args.some((arg)=>PROXY_FLAGS.some((flag)=>arg === flag || arg.startsWith(`${flag}=`)));
|
|
40
|
+
}
|
|
41
|
+
function proxyOutputRedactor(proxy) {
|
|
42
|
+
if (proxy === undefined) return (value)=>value;
|
|
43
|
+
const url = typeof proxy === 'string' ? proxy : proxy.url;
|
|
44
|
+
const redacted = url.replace(/^([a-z][a-z\d+.-]*:\/\/)([^/@]+)@/i, '$1***:***@');
|
|
45
|
+
if (redacted === url) return (value)=>value;
|
|
46
|
+
return (value)=>value.replaceAll(url, redacted);
|
|
47
|
+
}
|
|
15
48
|
function safeFilename(name) {
|
|
16
49
|
const filename = name.split(/[\\/]/).at(-1)?.replace(/[^\w.-]/g, '-');
|
|
17
50
|
return filename || 'file';
|
|
@@ -111,12 +144,34 @@ function outputSpec(args) {
|
|
|
111
144
|
const clientArgs = [
|
|
112
145
|
...opts.args ?? []
|
|
113
146
|
];
|
|
147
|
+
if (opts.proxy !== undefined && hasProxyFlag(clientArgs)) {
|
|
148
|
+
throw new Error('proxy cannot be combined with --proxy or --proxy-bypass args');
|
|
149
|
+
}
|
|
150
|
+
const clientEnv = proxyEnvironment(opts.proxy);
|
|
151
|
+
const redactProxy = proxyOutputRedactor(opts.proxy);
|
|
114
152
|
const ownsRunner = opts.ownsRunner ?? true;
|
|
153
|
+
const closeSession = opts.closeSession ?? true;
|
|
115
154
|
const recordings = new Map();
|
|
116
155
|
let closed = false;
|
|
117
156
|
function assertOpen() {
|
|
118
157
|
if (closed) throw new Error('AgentBrowser is closed');
|
|
119
158
|
}
|
|
159
|
+
async function runWithRedaction(cmd, args, options) {
|
|
160
|
+
try {
|
|
161
|
+
const run = await runner.run(cmd, args, options);
|
|
162
|
+
return {
|
|
163
|
+
...run,
|
|
164
|
+
stdout: redactProxy(run.stdout),
|
|
165
|
+
stderr: redactProxy(run.stderr)
|
|
166
|
+
};
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error instanceof Error) {
|
|
169
|
+
error.message = redactProxy(error.message);
|
|
170
|
+
if (error.stack) error.stack = redactProxy(error.stack);
|
|
171
|
+
}
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
120
175
|
async function execCommand(args, timeoutMs, useSession = session) {
|
|
121
176
|
const [name, ...rest] = args;
|
|
122
177
|
// File-producing commands get a temporary remote output path when the
|
|
@@ -150,8 +205,9 @@ function outputSpec(args) {
|
|
|
150
205
|
remotePath = recording?.path;
|
|
151
206
|
fileSpec = recording?.spec;
|
|
152
207
|
}
|
|
153
|
-
const run = await
|
|
154
|
-
timeoutMs
|
|
208
|
+
const run = await runWithRedaction('agent-browser', finalArgs, {
|
|
209
|
+
timeoutMs,
|
|
210
|
+
env: clientEnv
|
|
155
211
|
});
|
|
156
212
|
const result = {
|
|
157
213
|
args,
|
|
@@ -249,6 +305,30 @@ function outputSpec(args) {
|
|
|
249
305
|
ok: results.every((r)=>r.ok)
|
|
250
306
|
};
|
|
251
307
|
},
|
|
308
|
+
async shell (command, shellOpts = {}) {
|
|
309
|
+
assertOpen();
|
|
310
|
+
if (!command.trim()) throw new Error('shell command must not be empty');
|
|
311
|
+
const useSession = shellOpts.session ?? session;
|
|
312
|
+
if (!SESSION_NAME.test(useSession)) {
|
|
313
|
+
throw new Error(`session must match [A-Za-z0-9_.-]{1,64}, got "${useSession}"`);
|
|
314
|
+
}
|
|
315
|
+
const run = await runWithRedaction('sh', [
|
|
316
|
+
'-c',
|
|
317
|
+
command
|
|
318
|
+
], {
|
|
319
|
+
timeoutMs: shellOpts.timeoutMs,
|
|
320
|
+
env: {
|
|
321
|
+
...clientEnv,
|
|
322
|
+
AGENT_BROWSER_SESSION: useSession
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
return {
|
|
326
|
+
ok: run.exitCode === 0,
|
|
327
|
+
exitCode: run.exitCode,
|
|
328
|
+
stdout: run.stdout,
|
|
329
|
+
stderr: run.stderr
|
|
330
|
+
};
|
|
331
|
+
},
|
|
252
332
|
exec,
|
|
253
333
|
async upload (selector, files, execOpts = {}) {
|
|
254
334
|
assertOpen();
|
|
@@ -293,6 +373,15 @@ function outputSpec(args) {
|
|
|
293
373
|
result
|
|
294
374
|
};
|
|
295
375
|
},
|
|
376
|
+
async readFile (path, contentType = 'application/octet-stream') {
|
|
377
|
+
assertOpen();
|
|
378
|
+
if (!path) throw new Error('file path must not be empty');
|
|
379
|
+
return {
|
|
380
|
+
path,
|
|
381
|
+
bytes: await runner.readFile(path),
|
|
382
|
+
contentType
|
|
383
|
+
};
|
|
384
|
+
},
|
|
296
385
|
async snapshot (urlOrOptions, opts = {}) {
|
|
297
386
|
const url = typeof urlOrOptions === 'string' ? urlOrOptions : undefined;
|
|
298
387
|
const runOpts = typeof urlOrOptions === 'string' ? opts : urlOrOptions ?? {};
|
|
@@ -333,15 +422,25 @@ function outputSpec(args) {
|
|
|
333
422
|
result: run
|
|
334
423
|
};
|
|
335
424
|
},
|
|
425
|
+
keepalive (opts = {}) {
|
|
426
|
+
assertOpen();
|
|
427
|
+
if (!runner.keepalive) {
|
|
428
|
+
throw new Error('the command runner does not support keepalive');
|
|
429
|
+
}
|
|
430
|
+
return runner.keepalive(opts);
|
|
431
|
+
},
|
|
336
432
|
async close () {
|
|
337
433
|
if (closed) return;
|
|
338
434
|
closed = true;
|
|
339
435
|
// Close the CLI session's browser first so the sandbox can stop cleanly.
|
|
340
|
-
|
|
341
|
-
'
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
436
|
+
if (closeSession) {
|
|
437
|
+
await runner.run('agent-browser', withSession(session, clientArgs, [
|
|
438
|
+
'close'
|
|
439
|
+
]), {
|
|
440
|
+
timeoutMs: 15_000,
|
|
441
|
+
env: clientEnv
|
|
442
|
+
}).catch(()=>{});
|
|
443
|
+
}
|
|
345
444
|
if (ownsRunner) await runner.close();
|
|
346
445
|
}
|
|
347
446
|
};
|
|
@@ -354,13 +453,40 @@ const BASE_ENV = {
|
|
|
354
453
|
AGENT_BROWSER_DEFAULT_TIMEOUT: '25000'
|
|
355
454
|
};
|
|
356
455
|
/** CommandRunner backed by one Vercel Sandbox. */ class SandboxRunner {
|
|
357
|
-
constructor(sandbox, env){
|
|
456
|
+
constructor(sandbox, env, timeoutMs = DEFAULT_TIMEOUT_MS){
|
|
457
|
+
this.keepaliveStops = new Set();
|
|
358
458
|
this.sandbox = sandbox;
|
|
359
459
|
this.env = env;
|
|
460
|
+
this.timeoutMs = timeoutMs;
|
|
461
|
+
}
|
|
462
|
+
async acquire() {
|
|
463
|
+
if (!this.sandbox) throw new Error('browser sandbox is unavailable');
|
|
464
|
+
return this.sandbox;
|
|
465
|
+
}
|
|
466
|
+
async observe() {
|
|
467
|
+
if (!this.sandbox) return null;
|
|
468
|
+
const sandbox = await Sandbox.get({
|
|
469
|
+
name: this.sandbox.name,
|
|
470
|
+
resume: false
|
|
471
|
+
});
|
|
472
|
+
this.sandbox = sandbox;
|
|
473
|
+
return sandbox;
|
|
474
|
+
}
|
|
475
|
+
async destroyRuntime() {
|
|
476
|
+
await this.sandbox?.stop().catch(()=>{});
|
|
360
477
|
}
|
|
361
478
|
async run(cmd, args, opts = {}) {
|
|
362
479
|
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
363
|
-
|
|
480
|
+
// A command is activity: restore the configured idle window even when no
|
|
481
|
+
// explicit heartbeat is running. Renewal is best-effort so an observability
|
|
482
|
+
// or platform hiccup does not block useful browser work.
|
|
483
|
+
const hadSandbox = this.sandbox !== undefined;
|
|
484
|
+
let sandbox = await this.acquire();
|
|
485
|
+
if (hadSandbox) {
|
|
486
|
+
sandbox = await this.observe().catch(()=>null) ?? sandbox;
|
|
487
|
+
}
|
|
488
|
+
await this.restoreLifetime(sandbox, this.timeoutMs).catch(()=>{});
|
|
489
|
+
const command = await sandbox.runCommand({
|
|
364
490
|
cmd,
|
|
365
491
|
args,
|
|
366
492
|
env: {
|
|
@@ -370,8 +496,12 @@ const BASE_ENV = {
|
|
|
370
496
|
detached: true
|
|
371
497
|
});
|
|
372
498
|
let timer;
|
|
499
|
+
let timedOut = false;
|
|
373
500
|
const timeout = new Promise((_, reject)=>{
|
|
374
|
-
timer = setTimeout(()=>
|
|
501
|
+
timer = setTimeout(()=>{
|
|
502
|
+
timedOut = true;
|
|
503
|
+
reject(new Error(`command timed out after ${timeoutMs}ms`));
|
|
504
|
+
}, timeoutMs);
|
|
375
505
|
if (typeof timer.unref === 'function') timer.unref();
|
|
376
506
|
});
|
|
377
507
|
try {
|
|
@@ -391,11 +521,38 @@ const BASE_ENV = {
|
|
|
391
521
|
};
|
|
392
522
|
} catch (error) {
|
|
393
523
|
await command.kill().catch(()=>{});
|
|
524
|
+
if (timedOut) await this.reclaimBrowser(sandbox);
|
|
394
525
|
throw error;
|
|
395
526
|
} finally{
|
|
396
527
|
if (timer) clearTimeout(timer);
|
|
397
528
|
}
|
|
398
529
|
}
|
|
530
|
+
/** A timed-out CLI call may leave Chromium wedged; force a clean relaunch. */ async reclaimBrowser(sandbox) {
|
|
531
|
+
try {
|
|
532
|
+
const command = await sandbox.runCommand({
|
|
533
|
+
cmd: 'sh',
|
|
534
|
+
args: [
|
|
535
|
+
'-c',
|
|
536
|
+
'pkill -9 chromium || true; pkill -9 chrome || true'
|
|
537
|
+
],
|
|
538
|
+
detached: true
|
|
539
|
+
});
|
|
540
|
+
let timer;
|
|
541
|
+
const timeout = new Promise((resolve)=>{
|
|
542
|
+
// Cleanup must not turn the original command timeout into an unbounded wait.
|
|
543
|
+
timer = setTimeout(resolve, 30_000);
|
|
544
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
545
|
+
});
|
|
546
|
+
try {
|
|
547
|
+
await Promise.race([
|
|
548
|
+
command.wait(),
|
|
549
|
+
timeout
|
|
550
|
+
]);
|
|
551
|
+
} finally{
|
|
552
|
+
if (timer) clearTimeout(timer);
|
|
553
|
+
}
|
|
554
|
+
} catch {}
|
|
555
|
+
}
|
|
399
556
|
async readFile(path) {
|
|
400
557
|
const { stdout, exitCode, stderr } = await this.run('base64', [
|
|
401
558
|
'-w0',
|
|
@@ -409,17 +566,156 @@ const BASE_ENV = {
|
|
|
409
566
|
return Buffer.from(stdout.trim(), 'base64');
|
|
410
567
|
}
|
|
411
568
|
async writeFile(path, bytes) {
|
|
412
|
-
await this.
|
|
569
|
+
const sandbox = await this.acquire();
|
|
570
|
+
await sandbox.writeFiles([
|
|
413
571
|
{
|
|
414
572
|
path,
|
|
415
573
|
content: bytes
|
|
416
574
|
}
|
|
417
575
|
]);
|
|
418
576
|
}
|
|
577
|
+
/** Restore a sliding idle window without cumulatively adding that window. */ async restoreLifetime(sandbox, timeoutMs) {
|
|
578
|
+
if (sandbox.status !== 'running') return;
|
|
579
|
+
const targetExpiresAt = Date.now() + timeoutMs;
|
|
580
|
+
const currentExpiresAt = sandbox.expiresAt?.getTime();
|
|
581
|
+
const extensionMs = currentExpiresAt === undefined ? timeoutMs : Math.ceil(targetExpiresAt - currentExpiresAt);
|
|
582
|
+
if (extensionMs > 0) await sandbox.extendTimeout(extensionMs);
|
|
583
|
+
}
|
|
584
|
+
async renewLifetime(timeoutMs) {
|
|
585
|
+
// Poll without resuming: a late heartbeat must not resurrect an already
|
|
586
|
+
// expired Sandbox whose in-memory browser state is gone.
|
|
587
|
+
const sandbox = await this.observe();
|
|
588
|
+
if (!sandbox) return;
|
|
589
|
+
await this.restoreLifetime(sandbox, timeoutMs);
|
|
590
|
+
}
|
|
591
|
+
keepalive(opts = {}) {
|
|
592
|
+
const timeoutMs = opts.timeoutMs ?? this.timeoutMs;
|
|
593
|
+
const intervalMs = opts.intervalMs ?? Math.min(5 * 60 * 1000, Math.floor(timeoutMs / 2));
|
|
594
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
595
|
+
throw new RangeError('keepalive timeoutMs must be greater than 0');
|
|
596
|
+
}
|
|
597
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0 || intervalMs >= timeoutMs) {
|
|
598
|
+
throw new RangeError('keepalive intervalMs must be greater than 0 and less than timeoutMs');
|
|
599
|
+
}
|
|
600
|
+
let stopped = false;
|
|
601
|
+
let renewing = false;
|
|
602
|
+
const tick = async ()=>{
|
|
603
|
+
if (stopped || renewing) return;
|
|
604
|
+
renewing = true;
|
|
605
|
+
try {
|
|
606
|
+
await this.renewLifetime(timeoutMs);
|
|
607
|
+
} catch (error) {
|
|
608
|
+
opts.onError?.(error);
|
|
609
|
+
} finally{
|
|
610
|
+
renewing = false;
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
const timer = setInterval(()=>void tick(), intervalMs);
|
|
614
|
+
timer.unref?.();
|
|
615
|
+
void tick();
|
|
616
|
+
const stop = ()=>{
|
|
617
|
+
if (stopped) return;
|
|
618
|
+
stopped = true;
|
|
619
|
+
clearInterval(timer);
|
|
620
|
+
this.keepaliveStops.delete(stop);
|
|
621
|
+
};
|
|
622
|
+
this.keepaliveStops.add(stop);
|
|
623
|
+
return stop;
|
|
624
|
+
}
|
|
419
625
|
async close() {
|
|
420
|
-
|
|
626
|
+
for (const stop of [
|
|
627
|
+
...this.keepaliveStops
|
|
628
|
+
])stop();
|
|
629
|
+
await this.destroyRuntime();
|
|
421
630
|
}
|
|
422
631
|
}
|
|
632
|
+
function stableNames(id) {
|
|
633
|
+
const hash = createHash('sha256').update(id).digest('hex');
|
|
634
|
+
return {
|
|
635
|
+
sandbox: `remote-browser-${hash.slice(0, 32)}`,
|
|
636
|
+
session: `session-${hash.slice(0, 32)}`
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
function isMissingSandbox(error) {
|
|
640
|
+
if (!(error instanceof Error)) return false;
|
|
641
|
+
const code = error.json?.error?.code;
|
|
642
|
+
return code === 'not_found' || error.message.includes('Status code 404');
|
|
643
|
+
}
|
|
644
|
+
class NamedSandboxRunner extends SandboxRunner {
|
|
645
|
+
constructor(opts){
|
|
646
|
+
const names = stableNames(opts.id);
|
|
647
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
648
|
+
super(undefined, {
|
|
649
|
+
...BASE_ENV,
|
|
650
|
+
...opts.env
|
|
651
|
+
}, timeoutMs), this.resetListeners = new Set(), this.onResume = async ()=>{
|
|
652
|
+
const event = {
|
|
653
|
+
id: this.id,
|
|
654
|
+
reason: 'resumed'
|
|
655
|
+
};
|
|
656
|
+
for (const listener of this.resetListeners){
|
|
657
|
+
try {
|
|
658
|
+
listener(event);
|
|
659
|
+
} catch {}
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
this.id = opts.id;
|
|
663
|
+
this.name = names.sandbox;
|
|
664
|
+
this.session = names.session;
|
|
665
|
+
this.image = opts.image ?? process.env.REMOTE_AGENT_BROWSER_IMAGE ?? DEFAULT_BROWSER_IMAGE;
|
|
666
|
+
this.vcpus = opts.vcpus ?? 2;
|
|
667
|
+
}
|
|
668
|
+
on(event, listener) {
|
|
669
|
+
if (event !== 'reset') throw new Error(`unsupported browser event: ${event}`);
|
|
670
|
+
this.resetListeners.add(listener);
|
|
671
|
+
return ()=>this.resetListeners.delete(listener);
|
|
672
|
+
}
|
|
673
|
+
async acquire() {
|
|
674
|
+
if (this.sandbox) return this.sandbox;
|
|
675
|
+
this.sandbox = await Sandbox.getOrCreate({
|
|
676
|
+
name: this.name,
|
|
677
|
+
image: this.image,
|
|
678
|
+
resources: {
|
|
679
|
+
vcpus: this.vcpus
|
|
680
|
+
},
|
|
681
|
+
timeout: this.timeoutMs,
|
|
682
|
+
ports: [],
|
|
683
|
+
env: this.env,
|
|
684
|
+
persistent: false,
|
|
685
|
+
onResume: this.onResume
|
|
686
|
+
});
|
|
687
|
+
return this.sandbox;
|
|
688
|
+
}
|
|
689
|
+
async observe() {
|
|
690
|
+
try {
|
|
691
|
+
const sandbox = await Sandbox.get({
|
|
692
|
+
name: this.name,
|
|
693
|
+
resume: false,
|
|
694
|
+
onResume: this.onResume
|
|
695
|
+
});
|
|
696
|
+
this.sandbox = sandbox;
|
|
697
|
+
return sandbox;
|
|
698
|
+
} catch (error) {
|
|
699
|
+
if (isMissingSandbox(error)) return null;
|
|
700
|
+
throw error;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
async destroyRuntime() {
|
|
704
|
+
try {
|
|
705
|
+
const sandbox = this.sandbox ?? await Sandbox.get({
|
|
706
|
+
name: this.name,
|
|
707
|
+
resume: false
|
|
708
|
+
});
|
|
709
|
+
await sandbox.delete();
|
|
710
|
+
this.sandbox = undefined;
|
|
711
|
+
} catch (error) {
|
|
712
|
+
if (!isMissingSandbox(error)) throw error;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/** Create a lazy runner backed by a stable, project-scoped browser id. */ function createNamedBrowserSandboxRunner(opts) {
|
|
717
|
+
return new NamedSandboxRunner(opts);
|
|
718
|
+
}
|
|
423
719
|
/**
|
|
424
720
|
* Start an ephemeral Vercel Sandbox from the pre-built browser image.
|
|
425
721
|
*
|
|
@@ -432,17 +728,18 @@ const BASE_ENV = {
|
|
|
432
728
|
...BASE_ENV,
|
|
433
729
|
...opts.env
|
|
434
730
|
};
|
|
731
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
435
732
|
const sandbox = await Sandbox.create({
|
|
436
733
|
image,
|
|
437
734
|
resources: {
|
|
438
735
|
vcpus: opts.vcpus ?? 2
|
|
439
736
|
},
|
|
440
|
-
timeout:
|
|
737
|
+
timeout: timeoutMs,
|
|
441
738
|
ports: [],
|
|
442
739
|
env,
|
|
443
740
|
persistent: false
|
|
444
741
|
});
|
|
445
|
-
return new SandboxRunner(sandbox, env);
|
|
742
|
+
return new SandboxRunner(sandbox, env, timeoutMs);
|
|
446
743
|
}
|
|
447
744
|
|
|
448
745
|
/**
|
|
@@ -476,14 +773,50 @@ const BASE_ENV = {
|
|
|
476
773
|
return createBrowserClient(runner, {
|
|
477
774
|
session: opts.session,
|
|
478
775
|
args: opts.args,
|
|
776
|
+
proxy: opts.proxy,
|
|
777
|
+
ownsRunner: true
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Create a lazy browser handle with a stable logical id.
|
|
782
|
+
*
|
|
783
|
+
* The first operation finds or creates a named Vercel Sandbox. Later handles
|
|
784
|
+
* created with the same id attach to that Sandbox, including from another
|
|
785
|
+
* process. If an expired Sandbox resumes, listeners are told that its
|
|
786
|
+
* in-memory page, refs, cookies, and console history were reset.
|
|
787
|
+
*/ function createAgentBrowserSession(opts) {
|
|
788
|
+
if (!opts.id.trim()) throw new Error('browser id must not be empty');
|
|
789
|
+
const runner = createNamedBrowserSandboxRunner({
|
|
790
|
+
id: opts.id,
|
|
791
|
+
image: opts.image,
|
|
792
|
+
timeoutMs: opts.timeoutMs,
|
|
793
|
+
vcpus: opts.vcpus,
|
|
794
|
+
env: opts.env
|
|
795
|
+
});
|
|
796
|
+
const browser = createBrowserClient(runner, {
|
|
797
|
+
session: opts.session ?? runner.session,
|
|
798
|
+
args: opts.args,
|
|
799
|
+
proxy: opts.proxy,
|
|
800
|
+
closeSession: false,
|
|
479
801
|
ownsRunner: true
|
|
480
802
|
});
|
|
803
|
+
return {
|
|
804
|
+
...browser,
|
|
805
|
+
id: opts.id,
|
|
806
|
+
on (event, listener) {
|
|
807
|
+
return runner.on(event, listener);
|
|
808
|
+
},
|
|
809
|
+
destroy () {
|
|
810
|
+
return browser.close();
|
|
811
|
+
}
|
|
812
|
+
};
|
|
481
813
|
}
|
|
482
814
|
|
|
483
815
|
// Public library surface: create an agent browser in a Vercel Sandbox and
|
|
484
816
|
// drive it with the agent-browser CLI.
|
|
485
817
|
/** Create AgentBrowser clients backed by a fresh Vercel Sandbox. */ const AgentBrowser = {
|
|
486
|
-
create: createAgentBrowser
|
|
818
|
+
create: createAgentBrowser,
|
|
819
|
+
session: createAgentBrowserSession
|
|
487
820
|
};
|
|
488
821
|
|
|
489
|
-
export { AgentBrowser, createAgentBrowser, createBrowserClient };
|
|
822
|
+
export { AgentBrowser, createAgentBrowser, createAgentBrowserSession, createBrowserClient };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "remote-agent-browser",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.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": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"prepublishOnly": "node --run build",
|
|
20
20
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
21
21
|
"test": "node --run build && node --test tests/*.test.mjs",
|
|
22
|
-
"test:integration": "node --run build && node --test tests
|
|
22
|
+
"test:integration": "node --run build && node --test --test-concurrency=1 tests/*.integration.ts",
|
|
23
23
|
"test:unit": "node --run build && node --test tests/unit.test.mjs"
|
|
24
24
|
},
|
|
25
25
|
"files": [
|