remote-agent-browser 1.1.0 → 1.3.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 +103 -0
- package/dist/index.d.ts +58 -2
- package/dist/index.js +300 -39
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -3,6 +3,23 @@
|
|
|
3
3
|
Run [agent-browser](https://github.com/vercel-labs/agent-browser) in the cloud
|
|
4
4
|
with an isolated [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox).
|
|
5
5
|
|
|
6
|
+
## Benchmark
|
|
7
|
+
|
|
8
|
+
Loading and taking an interactive snapshot of 100 HTTPS pages in a fresh,
|
|
9
|
+
two-vCPU Vercel Sandbox:
|
|
10
|
+
|
|
11
|
+
| Metric | Result |
|
|
12
|
+
| --- | ---: |
|
|
13
|
+
| Execution time (100 pages) | 8.01s |
|
|
14
|
+
| Memory (peak, 100 pages) | 461.6MB PSS |
|
|
15
|
+
|
|
16
|
+
Execution time includes Sandbox creation. The published time is the median and
|
|
17
|
+
memory is the maximum from three successful runs using Chromium 151,
|
|
18
|
+
agent-browser 0.33.2, and `https://example.com/` with a unique query per page.
|
|
19
|
+
Each page must produce the expected content and interactive snapshot references.
|
|
20
|
+
|
|
21
|
+
See [the benchmark methodology](./benchmarks/BENCHMARK.md) for details.
|
|
22
|
+
|
|
6
23
|
## Install
|
|
7
24
|
|
|
8
25
|
```bash
|
|
@@ -31,6 +48,27 @@ references. `close()` closes Chromium and stops the Sandbox.
|
|
|
31
48
|
Authentication is automatic when running on Vercel through
|
|
32
49
|
`VERCEL_OIDC_TOKEN`.
|
|
33
50
|
|
|
51
|
+
To run browsers in another team/project, supply explicit short-lived credentials:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
const browser = AgentBrowser.session({
|
|
55
|
+
id: `preview:${chatId}`,
|
|
56
|
+
resolveSandboxCredentials: async () => ({
|
|
57
|
+
teamId: resourceTeamId,
|
|
58
|
+
projectId: resourceProjectId,
|
|
59
|
+
token: await getShortLivedTeamToken(),
|
|
60
|
+
}),
|
|
61
|
+
})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The same option works with `AgentBrowser.create()`. Keep the team and project
|
|
65
|
+
fixed for the handle's lifetime; refresh only the token. The resolver is lazy
|
|
66
|
+
for named sessions and runs before creation, cached-runtime command/file access,
|
|
67
|
+
observation/keepalive, and cleanup. A resolver failure rejects the operation;
|
|
68
|
+
commands do not fall back to cached credentials. Returning `undefined` preserves
|
|
69
|
+
deployment authentication. Credentials are not passed into browser command env.
|
|
70
|
+
|
|
71
|
+
|
|
34
72
|
For local development, link a Vercel project and pull its environment:
|
|
35
73
|
|
|
36
74
|
```bash
|
|
@@ -102,6 +140,25 @@ await browser.exec('find', {
|
|
|
102
140
|
})
|
|
103
141
|
```
|
|
104
142
|
|
|
143
|
+
### `browser.shell(command, options?)`
|
|
144
|
+
|
|
145
|
+
Use this when forwarding a Bash-tool command or composing `agent-browser` with
|
|
146
|
+
normal shell utilities. The command runs verbatim, so quoting, pipes,
|
|
147
|
+
redirection, and control operators keep their shell semantics. Prefer `exec()`
|
|
148
|
+
or `run()` for browser commands that do not need a shell. Every `agent-browser`
|
|
149
|
+
invocation inherits the client's CLI session through `AGENT_BROWSER_SESSION`.
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
const result = await browser.shell(
|
|
153
|
+
'agent-browser read "https://example.com" | grep -io "example" | wc -l',
|
|
154
|
+
)
|
|
155
|
+
console.log(result.stdout)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Client-wide `args` are not inserted into a shell string because doing so would
|
|
159
|
+
require rewriting arbitrary shell syntax. Put global CLI arguments directly in
|
|
160
|
+
the command when using `shell()`.
|
|
161
|
+
|
|
105
162
|
### Typed JSON
|
|
106
163
|
|
|
107
164
|
Set `output: 'json'` for commands that support `--json`. `exec<T>()` adds the
|
|
@@ -143,6 +200,17 @@ const result = await browser.exec('network', { args: ['har', 'stop'] })
|
|
|
143
200
|
await writeFile('capture.har', result.file.bytes)
|
|
144
201
|
```
|
|
145
202
|
|
|
203
|
+
When a command already contains an explicit remote output path, read it back
|
|
204
|
+
without changing the command:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
await browser.shell(
|
|
208
|
+
'agent-browser screenshot /tmp/verification.png',
|
|
209
|
+
)
|
|
210
|
+
const file = await browser.readFile('/tmp/verification.png', 'image/png')
|
|
211
|
+
await writeFile('verification.png', file.bytes)
|
|
212
|
+
```
|
|
213
|
+
|
|
146
214
|
### Convenience methods
|
|
147
215
|
|
|
148
216
|
- `browser.snapshot(url?)` optionally opens a page, then returns its interactive
|
|
@@ -153,6 +221,41 @@ await writeFile('capture.har', result.file.bytes)
|
|
|
153
221
|
|
|
154
222
|
All methods use the same disposable browser session until `close()` is called.
|
|
155
223
|
|
|
224
|
+
### Stable browser ids
|
|
225
|
+
|
|
226
|
+
Use `AgentBrowser.session()` when browser identity must survive process
|
|
227
|
+
boundaries. It returns a lazy handle: constructing it or starting keepalive
|
|
228
|
+
does not create a Sandbox. The first browser command finds or creates a runtime
|
|
229
|
+
derived from the caller-defined id, and another process using the same id finds
|
|
230
|
+
that runtime again.
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
const browser = AgentBrowser.session({ id: `environment:${chatId}` })
|
|
234
|
+
const stopKeepalive = browser.keepalive()
|
|
235
|
+
try {
|
|
236
|
+
await browser.exec('open', { args: ['https://example.com'] })
|
|
237
|
+
} finally {
|
|
238
|
+
stopKeepalive()
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Ids are project-scoped. Include the deployment environment or another namespace
|
|
243
|
+
when the same application uses one Vercel project for multiple environments.
|
|
244
|
+
The underlying Sandbox name is private and derived from a hash of the id.
|
|
245
|
+
|
|
246
|
+
If an expired Sandbox resumes, its Chromium process starts fresh. Subscribe to
|
|
247
|
+
`reset` before running commands when the caller needs to surface that page,
|
|
248
|
+
cookies, refs, tabs, and console history were lost:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
browser.on('reset', ({ reason }) => {
|
|
252
|
+
console.log(`Browser runtime reset: ${reason}`)
|
|
253
|
+
})
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
`browser.destroy()` permanently removes the named runtime. Stopping keepalive
|
|
257
|
+
only allows its normal idle timeout to resume.
|
|
258
|
+
|
|
156
259
|
### Keepalive
|
|
157
260
|
|
|
158
261
|
Keep the Sandbox alive across idle gaps during long-running work. Always stop
|
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;
|
|
@@ -72,6 +84,8 @@ type BrowserClientOptions = {
|
|
|
72
84
|
args?: string[];
|
|
73
85
|
/** Proxy configuration fixed for this client's lifetime. */
|
|
74
86
|
proxy?: ProxyOptions;
|
|
87
|
+
/** Run `agent-browser close` before closing the runner. Default: true. */
|
|
88
|
+
closeSession?: boolean;
|
|
75
89
|
/** Whether close() also tears down the command runner. Default: true. */
|
|
76
90
|
ownsRunner?: boolean;
|
|
77
91
|
};
|
|
@@ -83,6 +97,12 @@ type KeepaliveOptions = {
|
|
|
83
97
|
/** Optional observer for best-effort heartbeat failures. */
|
|
84
98
|
onError?: (error: unknown) => void;
|
|
85
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
|
+
};
|
|
86
106
|
/**
|
|
87
107
|
* Minimal command executor the browser client needs. Implemented by the
|
|
88
108
|
* Vercel Sandbox adapter; anything with the same shape can back a
|
|
@@ -113,6 +133,8 @@ interface AgentBrowser$1 {
|
|
|
113
133
|
readonly session: string;
|
|
114
134
|
/** Run a batch of agent-browser arg-arrays sequentially. */
|
|
115
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>;
|
|
116
138
|
/** Run one command and unwrap its typed --json data payload. */
|
|
117
139
|
exec<T>(command: string, opts: JsonExecOptions): Promise<BrowserCommandResult<T>>;
|
|
118
140
|
/** Run one agent-browser command with text output. */
|
|
@@ -126,6 +148,8 @@ interface AgentBrowser$1 {
|
|
|
126
148
|
file: BrowserFile;
|
|
127
149
|
result: BrowserCommandResult;
|
|
128
150
|
}>;
|
|
151
|
+
/** Read an explicitly named artifact from the browser environment. */
|
|
152
|
+
readFile(path: string, contentType?: string): Promise<BrowserFile>;
|
|
129
153
|
/** Optionally open a URL, then return the interactive accessibility snapshot. */
|
|
130
154
|
snapshot(urlOrOptions?: string | RunOptions, opts?: RunOptions): Promise<BrowserRunResult>;
|
|
131
155
|
/** Optionally open a URL, then return a PNG screenshot. */
|
|
@@ -141,8 +165,26 @@ interface AgentBrowser$1 {
|
|
|
141
165
|
/** Close the browser session (and the sandbox when it owns it). */
|
|
142
166
|
close(): Promise<void>;
|
|
143
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
|
+
}
|
|
177
|
+
/** Explicit Sandbox scope; resolve a fresh token while keeping team/project fixed. */
|
|
178
|
+
type SandboxCredentials = {
|
|
179
|
+
token: string;
|
|
180
|
+
teamId: string;
|
|
181
|
+
projectId: string;
|
|
182
|
+
};
|
|
183
|
+
type ResolveSandboxCredentials = () => Promise<SandboxCredentials | undefined>;
|
|
144
184
|
|
|
145
185
|
type CreateAgentBrowserOptions = {
|
|
186
|
+
/** Fresh credentials for a fixed Sandbox scope; omit for deployment auth. */
|
|
187
|
+
resolveSandboxCredentials?: ResolveSandboxCredentials;
|
|
146
188
|
/** Named CLI session; state (page, cookies, refs) persists across calls. */
|
|
147
189
|
session?: string;
|
|
148
190
|
/**
|
|
@@ -161,6 +203,10 @@ type CreateAgentBrowserOptions = {
|
|
|
161
203
|
/** Proxy configuration fixed for this browser's lifetime. */
|
|
162
204
|
proxy?: ProxyOptions;
|
|
163
205
|
};
|
|
206
|
+
type CreateAgentBrowserSessionOptions = CreateAgentBrowserOptions & {
|
|
207
|
+
/** Stable logical identity used to find this browser across processes. */
|
|
208
|
+
id: string;
|
|
209
|
+
};
|
|
164
210
|
/**
|
|
165
211
|
* Create an agent-browser client running inside a fresh Vercel Sandbox.
|
|
166
212
|
*
|
|
@@ -184,6 +230,15 @@ type CreateAgentBrowserOptions = {
|
|
|
184
230
|
* ```
|
|
185
231
|
*/
|
|
186
232
|
declare function createAgentBrowser(opts?: CreateAgentBrowserOptions): Promise<AgentBrowser$1>;
|
|
233
|
+
/**
|
|
234
|
+
* Create a lazy browser handle with a stable logical id.
|
|
235
|
+
*
|
|
236
|
+
* The first operation finds or creates a named Vercel Sandbox. Later handles
|
|
237
|
+
* created with the same id attach to that Sandbox, including from another
|
|
238
|
+
* process. If an expired Sandbox resumes, listeners are told that its
|
|
239
|
+
* in-memory page, refs, cookies, and console history were reset.
|
|
240
|
+
*/
|
|
241
|
+
declare function createAgentBrowserSession(opts: CreateAgentBrowserSessionOptions): AgentBrowserSession;
|
|
187
242
|
|
|
188
243
|
/**
|
|
189
244
|
* Create a session-oriented browser client over any CommandRunner.
|
|
@@ -198,8 +253,9 @@ declare function createBrowserClient(runner: CommandRunner, opts?: BrowserClient
|
|
|
198
253
|
/** Create AgentBrowser clients backed by a fresh Vercel Sandbox. */
|
|
199
254
|
declare const AgentBrowser: {
|
|
200
255
|
create: typeof createAgentBrowser;
|
|
256
|
+
session: typeof createAgentBrowserSession;
|
|
201
257
|
};
|
|
202
258
|
type AgentBrowser = AgentBrowser$1;
|
|
203
259
|
|
|
204
|
-
export { AgentBrowser, createAgentBrowser, createBrowserClient };
|
|
205
|
-
export type { BrowserClientOptions, BrowserCommandResult, BrowserRunResult, CommandRunner, CreateAgentBrowserOptions, ExecOptions, JsonExecOptions, KeepaliveOptions, ProxyOptions, RunOptions };
|
|
260
|
+
export { AgentBrowser, createAgentBrowser, createAgentBrowserSession, createBrowserClient };
|
|
261
|
+
export type { AgentBrowserSession, BrowserClientOptions, BrowserCommandResult, BrowserFile, BrowserRunResult, BrowserRuntimeResetEvent, BrowserShellResult, CommandRunner, CreateAgentBrowserOptions, CreateAgentBrowserSessionOptions, ExecOptions, JsonExecOptions, KeepaliveOptions, ProxyOptions, ResolveSandboxCredentials, RunOptions, SandboxCredentials, 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([
|
|
@@ -150,11 +150,28 @@ function outputSpec(args) {
|
|
|
150
150
|
const clientEnv = proxyEnvironment(opts.proxy);
|
|
151
151
|
const redactProxy = proxyOutputRedactor(opts.proxy);
|
|
152
152
|
const ownsRunner = opts.ownsRunner ?? true;
|
|
153
|
+
const closeSession = opts.closeSession ?? true;
|
|
153
154
|
const recordings = new Map();
|
|
154
155
|
let closed = false;
|
|
155
156
|
function assertOpen() {
|
|
156
157
|
if (closed) throw new Error('AgentBrowser is closed');
|
|
157
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
|
+
}
|
|
158
175
|
async function execCommand(args, timeoutMs, useSession = session) {
|
|
159
176
|
const [name, ...rest] = args;
|
|
160
177
|
// File-producing commands get a temporary remote output path when the
|
|
@@ -188,25 +205,16 @@ function outputSpec(args) {
|
|
|
188
205
|
remotePath = recording?.path;
|
|
189
206
|
fileSpec = recording?.spec;
|
|
190
207
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
env: clientEnv
|
|
196
|
-
});
|
|
197
|
-
} catch (error) {
|
|
198
|
-
if (error instanceof Error) {
|
|
199
|
-
error.message = redactProxy(error.message);
|
|
200
|
-
if (error.stack) error.stack = redactProxy(error.stack);
|
|
201
|
-
}
|
|
202
|
-
throw error;
|
|
203
|
-
}
|
|
208
|
+
const run = await runWithRedaction('agent-browser', finalArgs, {
|
|
209
|
+
timeoutMs,
|
|
210
|
+
env: clientEnv
|
|
211
|
+
});
|
|
204
212
|
const result = {
|
|
205
213
|
args,
|
|
206
214
|
ok: run.exitCode === 0,
|
|
207
215
|
exitCode: run.exitCode,
|
|
208
|
-
stdout:
|
|
209
|
-
stderr:
|
|
216
|
+
stdout: run.stdout,
|
|
217
|
+
stderr: run.stderr
|
|
210
218
|
};
|
|
211
219
|
if (result.ok && startsRecording && remotePath && fileSpec) {
|
|
212
220
|
recordings.set(useSession, {
|
|
@@ -297,6 +305,30 @@ function outputSpec(args) {
|
|
|
297
305
|
ok: results.every((r)=>r.ok)
|
|
298
306
|
};
|
|
299
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
|
+
},
|
|
300
332
|
exec,
|
|
301
333
|
async upload (selector, files, execOpts = {}) {
|
|
302
334
|
assertOpen();
|
|
@@ -341,6 +373,15 @@ function outputSpec(args) {
|
|
|
341
373
|
result
|
|
342
374
|
};
|
|
343
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
|
+
},
|
|
344
385
|
async snapshot (urlOrOptions, opts = {}) {
|
|
345
386
|
const url = typeof urlOrOptions === 'string' ? urlOrOptions : undefined;
|
|
346
387
|
const runOpts = typeof urlOrOptions === 'string' ? opts : urlOrOptions ?? {};
|
|
@@ -392,12 +433,14 @@ function outputSpec(args) {
|
|
|
392
433
|
if (closed) return;
|
|
393
434
|
closed = true;
|
|
394
435
|
// Close the CLI session's browser first so the sandbox can stop cleanly.
|
|
395
|
-
|
|
396
|
-
'
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
+
}
|
|
401
444
|
if (ownsRunner) await runner.close();
|
|
402
445
|
}
|
|
403
446
|
};
|
|
@@ -410,15 +453,60 @@ const BASE_ENV = {
|
|
|
410
453
|
AGENT_BROWSER_DEFAULT_TIMEOUT: '25000'
|
|
411
454
|
};
|
|
412
455
|
/** CommandRunner backed by one Vercel Sandbox. */ class SandboxRunner {
|
|
413
|
-
constructor(sandbox, env, timeoutMs = DEFAULT_TIMEOUT_MS){
|
|
456
|
+
constructor(sandbox, env, timeoutMs = DEFAULT_TIMEOUT_MS, resolveSandboxCredentials){
|
|
414
457
|
this.keepaliveStops = new Set();
|
|
415
458
|
this.sandbox = sandbox;
|
|
416
459
|
this.env = env;
|
|
417
460
|
this.timeoutMs = timeoutMs;
|
|
461
|
+
this.resolveSandboxCredentials = resolveSandboxCredentials;
|
|
462
|
+
}
|
|
463
|
+
async acquire() {
|
|
464
|
+
if (!this.sandbox) throw new Error('browser sandbox is unavailable');
|
|
465
|
+
return this.sandbox;
|
|
466
|
+
}
|
|
467
|
+
async observe() {
|
|
468
|
+
return this.observeWithCredentials(await this.resolveSandboxCredentials?.());
|
|
469
|
+
}
|
|
470
|
+
async observeWithCredentials(credentials) {
|
|
471
|
+
if (!this.sandbox) return null;
|
|
472
|
+
const sandbox = await Sandbox.get({
|
|
473
|
+
...credentials,
|
|
474
|
+
name: this.sandbox.name,
|
|
475
|
+
resume: false
|
|
476
|
+
});
|
|
477
|
+
this.sandbox = sandbox;
|
|
478
|
+
return sandbox;
|
|
479
|
+
}
|
|
480
|
+
async destroyRuntime() {
|
|
481
|
+
if (this.resolveSandboxCredentials) {
|
|
482
|
+
const credentials = await this.resolveSandboxCredentials();
|
|
483
|
+
if (credentials) {
|
|
484
|
+
const sandbox = await this.observeWithCredentials(credentials);
|
|
485
|
+
await sandbox?.stop();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
await this.sandbox?.stop().catch(()=>{});
|
|
418
490
|
}
|
|
419
491
|
async run(cmd, args, opts = {}) {
|
|
420
492
|
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
421
|
-
|
|
493
|
+
// A command is activity: restore the configured idle window even when no
|
|
494
|
+
// explicit heartbeat is running. Renewal is best-effort so an observability
|
|
495
|
+
// or platform hiccup does not block useful browser work.
|
|
496
|
+
const hadSandbox = this.sandbox !== undefined;
|
|
497
|
+
let sandbox = await this.acquire();
|
|
498
|
+
if (hadSandbox) {
|
|
499
|
+
const credentials = await this.resolveSandboxCredentials?.();
|
|
500
|
+
if (credentials) {
|
|
501
|
+
const observed = await this.observeWithCredentials(credentials);
|
|
502
|
+
if (!observed) throw new Error('browser sandbox is unavailable');
|
|
503
|
+
sandbox = observed;
|
|
504
|
+
} else {
|
|
505
|
+
sandbox = await this.observeWithCredentials(undefined).catch(()=>null) ?? sandbox;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
await this.restoreLifetime(sandbox, this.timeoutMs).catch(()=>{});
|
|
509
|
+
const command = await sandbox.runCommand({
|
|
422
510
|
cmd,
|
|
423
511
|
args,
|
|
424
512
|
env: {
|
|
@@ -428,8 +516,12 @@ const BASE_ENV = {
|
|
|
428
516
|
detached: true
|
|
429
517
|
});
|
|
430
518
|
let timer;
|
|
519
|
+
let timedOut = false;
|
|
431
520
|
const timeout = new Promise((_, reject)=>{
|
|
432
|
-
timer = setTimeout(()=>
|
|
521
|
+
timer = setTimeout(()=>{
|
|
522
|
+
timedOut = true;
|
|
523
|
+
reject(new Error(`command timed out after ${timeoutMs}ms`));
|
|
524
|
+
}, timeoutMs);
|
|
433
525
|
if (typeof timer.unref === 'function') timer.unref();
|
|
434
526
|
});
|
|
435
527
|
try {
|
|
@@ -449,11 +541,38 @@ const BASE_ENV = {
|
|
|
449
541
|
};
|
|
450
542
|
} catch (error) {
|
|
451
543
|
await command.kill().catch(()=>{});
|
|
544
|
+
if (timedOut) await this.reclaimBrowser(sandbox);
|
|
452
545
|
throw error;
|
|
453
546
|
} finally{
|
|
454
547
|
if (timer) clearTimeout(timer);
|
|
455
548
|
}
|
|
456
549
|
}
|
|
550
|
+
/** A timed-out CLI call may leave Chromium wedged; force a clean relaunch. */ async reclaimBrowser(sandbox) {
|
|
551
|
+
try {
|
|
552
|
+
const command = await sandbox.runCommand({
|
|
553
|
+
cmd: 'sh',
|
|
554
|
+
args: [
|
|
555
|
+
'-c',
|
|
556
|
+
'pkill -9 chromium || true; pkill -9 chrome || true'
|
|
557
|
+
],
|
|
558
|
+
detached: true
|
|
559
|
+
});
|
|
560
|
+
let timer;
|
|
561
|
+
const timeout = new Promise((resolve)=>{
|
|
562
|
+
// Cleanup must not turn the original command timeout into an unbounded wait.
|
|
563
|
+
timer = setTimeout(resolve, 30_000);
|
|
564
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
565
|
+
});
|
|
566
|
+
try {
|
|
567
|
+
await Promise.race([
|
|
568
|
+
command.wait(),
|
|
569
|
+
timeout
|
|
570
|
+
]);
|
|
571
|
+
} finally{
|
|
572
|
+
if (timer) clearTimeout(timer);
|
|
573
|
+
}
|
|
574
|
+
} catch {}
|
|
575
|
+
}
|
|
457
576
|
async readFile(path) {
|
|
458
577
|
const { stdout, exitCode, stderr } = await this.run('base64', [
|
|
459
578
|
'-w0',
|
|
@@ -467,27 +586,37 @@ const BASE_ENV = {
|
|
|
467
586
|
return Buffer.from(stdout.trim(), 'base64');
|
|
468
587
|
}
|
|
469
588
|
async writeFile(path, bytes) {
|
|
470
|
-
|
|
589
|
+
const hadSandbox = this.sandbox !== undefined;
|
|
590
|
+
let sandbox = await this.acquire();
|
|
591
|
+
if (hadSandbox) {
|
|
592
|
+
const credentials = await this.resolveSandboxCredentials?.();
|
|
593
|
+
if (credentials) {
|
|
594
|
+
const observed = await this.observeWithCredentials(credentials);
|
|
595
|
+
if (!observed) throw new Error('browser sandbox is unavailable');
|
|
596
|
+
sandbox = observed;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
await sandbox.writeFiles([
|
|
471
600
|
{
|
|
472
601
|
path,
|
|
473
602
|
content: bytes
|
|
474
603
|
}
|
|
475
604
|
]);
|
|
476
605
|
}
|
|
477
|
-
/** Restore a sliding idle window without cumulatively adding that window. */ async
|
|
478
|
-
// Poll without resuming: a late heartbeat must not resurrect an already
|
|
479
|
-
// expired Sandbox whose in-memory browser state is gone.
|
|
480
|
-
const sandbox = await Sandbox.get({
|
|
481
|
-
name: this.sandbox.name,
|
|
482
|
-
resume: false
|
|
483
|
-
});
|
|
606
|
+
/** Restore a sliding idle window without cumulatively adding that window. */ async restoreLifetime(sandbox, timeoutMs) {
|
|
484
607
|
if (sandbox.status !== 'running') return;
|
|
485
|
-
this.sandbox = sandbox;
|
|
486
608
|
const targetExpiresAt = Date.now() + timeoutMs;
|
|
487
609
|
const currentExpiresAt = sandbox.expiresAt?.getTime();
|
|
488
610
|
const extensionMs = currentExpiresAt === undefined ? timeoutMs : Math.ceil(targetExpiresAt - currentExpiresAt);
|
|
489
611
|
if (extensionMs > 0) await sandbox.extendTimeout(extensionMs);
|
|
490
612
|
}
|
|
613
|
+
async renewLifetime(timeoutMs) {
|
|
614
|
+
// Poll without resuming: a late heartbeat must not resurrect an already
|
|
615
|
+
// expired Sandbox whose in-memory browser state is gone.
|
|
616
|
+
const sandbox = await this.observe();
|
|
617
|
+
if (!sandbox) return;
|
|
618
|
+
await this.restoreLifetime(sandbox, timeoutMs);
|
|
619
|
+
}
|
|
491
620
|
keepalive(opts = {}) {
|
|
492
621
|
const timeoutMs = opts.timeoutMs ?? this.timeoutMs;
|
|
493
622
|
const intervalMs = opts.intervalMs ?? Math.min(5 * 60 * 1000, Math.floor(timeoutMs / 2));
|
|
@@ -526,9 +655,103 @@ const BASE_ENV = {
|
|
|
526
655
|
for (const stop of [
|
|
527
656
|
...this.keepaliveStops
|
|
528
657
|
])stop();
|
|
529
|
-
await this.
|
|
658
|
+
await this.destroyRuntime();
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
function stableNames(id) {
|
|
662
|
+
const hash = createHash('sha256').update(id).digest('hex');
|
|
663
|
+
return {
|
|
664
|
+
sandbox: `remote-browser-${hash.slice(0, 32)}`,
|
|
665
|
+
session: `session-${hash.slice(0, 32)}`
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
function isMissingSandbox(error) {
|
|
669
|
+
if (!(error instanceof Error)) return false;
|
|
670
|
+
const code = error.json?.error?.code;
|
|
671
|
+
return code === 'not_found' || error.message.includes('Status code 404');
|
|
672
|
+
}
|
|
673
|
+
class NamedSandboxRunner extends SandboxRunner {
|
|
674
|
+
constructor(opts){
|
|
675
|
+
const names = stableNames(opts.id);
|
|
676
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
677
|
+
super(undefined, {
|
|
678
|
+
...BASE_ENV,
|
|
679
|
+
...opts.env
|
|
680
|
+
}, timeoutMs, opts.resolveSandboxCredentials), this.resetListeners = new Set(), this.onResume = async ()=>{
|
|
681
|
+
const event = {
|
|
682
|
+
id: this.id,
|
|
683
|
+
reason: 'resumed'
|
|
684
|
+
};
|
|
685
|
+
for (const listener of this.resetListeners){
|
|
686
|
+
try {
|
|
687
|
+
listener(event);
|
|
688
|
+
} catch {}
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
this.id = opts.id;
|
|
692
|
+
this.name = names.sandbox;
|
|
693
|
+
this.session = names.session;
|
|
694
|
+
this.image = opts.image ?? process.env.REMOTE_AGENT_BROWSER_IMAGE ?? DEFAULT_BROWSER_IMAGE;
|
|
695
|
+
this.vcpus = opts.vcpus ?? 2;
|
|
696
|
+
}
|
|
697
|
+
on(event, listener) {
|
|
698
|
+
if (event !== 'reset') throw new Error(`unsupported browser event: ${event}`);
|
|
699
|
+
this.resetListeners.add(listener);
|
|
700
|
+
return ()=>this.resetListeners.delete(listener);
|
|
701
|
+
}
|
|
702
|
+
async acquire() {
|
|
703
|
+
if (this.sandbox) return this.sandbox;
|
|
704
|
+
this.sandbox = await Sandbox.getOrCreate({
|
|
705
|
+
...await this.resolveSandboxCredentials?.(),
|
|
706
|
+
name: this.name,
|
|
707
|
+
image: this.image,
|
|
708
|
+
resources: {
|
|
709
|
+
vcpus: this.vcpus
|
|
710
|
+
},
|
|
711
|
+
timeout: this.timeoutMs,
|
|
712
|
+
ports: [],
|
|
713
|
+
env: this.env,
|
|
714
|
+
persistent: false,
|
|
715
|
+
onResume: this.onResume
|
|
716
|
+
});
|
|
717
|
+
return this.sandbox;
|
|
718
|
+
}
|
|
719
|
+
async observeWithCredentials(credentials) {
|
|
720
|
+
try {
|
|
721
|
+
const sandbox = await Sandbox.get({
|
|
722
|
+
...credentials,
|
|
723
|
+
name: this.name,
|
|
724
|
+
resume: false,
|
|
725
|
+
onResume: this.onResume
|
|
726
|
+
});
|
|
727
|
+
this.sandbox = sandbox;
|
|
728
|
+
return sandbox;
|
|
729
|
+
} catch (error) {
|
|
730
|
+
if (isMissingSandbox(error)) return null;
|
|
731
|
+
throw error;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
async destroyRuntime() {
|
|
735
|
+
try {
|
|
736
|
+
const credentials = await this.resolveSandboxCredentials?.();
|
|
737
|
+
let sandbox = this.sandbox;
|
|
738
|
+
if (credentials || !sandbox) {
|
|
739
|
+
sandbox = await Sandbox.get({
|
|
740
|
+
...credentials,
|
|
741
|
+
name: this.name,
|
|
742
|
+
resume: false
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
await sandbox.delete();
|
|
746
|
+
this.sandbox = undefined;
|
|
747
|
+
} catch (error) {
|
|
748
|
+
if (!isMissingSandbox(error)) throw error;
|
|
749
|
+
}
|
|
530
750
|
}
|
|
531
751
|
}
|
|
752
|
+
/** Create a lazy runner backed by a stable, project-scoped browser id. */ function createNamedBrowserSandboxRunner(opts) {
|
|
753
|
+
return new NamedSandboxRunner(opts);
|
|
754
|
+
}
|
|
532
755
|
/**
|
|
533
756
|
* Start an ephemeral Vercel Sandbox from the pre-built browser image.
|
|
534
757
|
*
|
|
@@ -543,6 +766,7 @@ const BASE_ENV = {
|
|
|
543
766
|
};
|
|
544
767
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
545
768
|
const sandbox = await Sandbox.create({
|
|
769
|
+
...await opts.resolveSandboxCredentials?.(),
|
|
546
770
|
image,
|
|
547
771
|
resources: {
|
|
548
772
|
vcpus: opts.vcpus ?? 2
|
|
@@ -552,7 +776,7 @@ const BASE_ENV = {
|
|
|
552
776
|
env,
|
|
553
777
|
persistent: false
|
|
554
778
|
});
|
|
555
|
-
return new SandboxRunner(sandbox, env, timeoutMs);
|
|
779
|
+
return new SandboxRunner(sandbox, env, timeoutMs, opts.resolveSandboxCredentials);
|
|
556
780
|
}
|
|
557
781
|
|
|
558
782
|
/**
|
|
@@ -581,7 +805,8 @@ const BASE_ENV = {
|
|
|
581
805
|
image: opts.image,
|
|
582
806
|
timeoutMs: opts.timeoutMs,
|
|
583
807
|
vcpus: opts.vcpus,
|
|
584
|
-
env: opts.env
|
|
808
|
+
env: opts.env,
|
|
809
|
+
resolveSandboxCredentials: opts.resolveSandboxCredentials
|
|
585
810
|
});
|
|
586
811
|
return createBrowserClient(runner, {
|
|
587
812
|
session: opts.session,
|
|
@@ -590,11 +815,47 @@ const BASE_ENV = {
|
|
|
590
815
|
ownsRunner: true
|
|
591
816
|
});
|
|
592
817
|
}
|
|
818
|
+
/**
|
|
819
|
+
* Create a lazy browser handle with a stable logical id.
|
|
820
|
+
*
|
|
821
|
+
* The first operation finds or creates a named Vercel Sandbox. Later handles
|
|
822
|
+
* created with the same id attach to that Sandbox, including from another
|
|
823
|
+
* process. If an expired Sandbox resumes, listeners are told that its
|
|
824
|
+
* in-memory page, refs, cookies, and console history were reset.
|
|
825
|
+
*/ function createAgentBrowserSession(opts) {
|
|
826
|
+
if (!opts.id.trim()) throw new Error('browser id must not be empty');
|
|
827
|
+
const runner = createNamedBrowserSandboxRunner({
|
|
828
|
+
id: opts.id,
|
|
829
|
+
image: opts.image,
|
|
830
|
+
timeoutMs: opts.timeoutMs,
|
|
831
|
+
vcpus: opts.vcpus,
|
|
832
|
+
env: opts.env,
|
|
833
|
+
resolveSandboxCredentials: opts.resolveSandboxCredentials
|
|
834
|
+
});
|
|
835
|
+
const browser = createBrowserClient(runner, {
|
|
836
|
+
session: opts.session ?? runner.session,
|
|
837
|
+
args: opts.args,
|
|
838
|
+
proxy: opts.proxy,
|
|
839
|
+
closeSession: false,
|
|
840
|
+
ownsRunner: true
|
|
841
|
+
});
|
|
842
|
+
return {
|
|
843
|
+
...browser,
|
|
844
|
+
id: opts.id,
|
|
845
|
+
on (event, listener) {
|
|
846
|
+
return runner.on(event, listener);
|
|
847
|
+
},
|
|
848
|
+
destroy () {
|
|
849
|
+
return browser.close();
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
}
|
|
593
853
|
|
|
594
854
|
// Public library surface: create an agent browser in a Vercel Sandbox and
|
|
595
855
|
// drive it with the agent-browser CLI.
|
|
596
856
|
/** Create AgentBrowser clients backed by a fresh Vercel Sandbox. */ const AgentBrowser = {
|
|
597
|
-
create: createAgentBrowser
|
|
857
|
+
create: createAgentBrowser,
|
|
858
|
+
session: createAgentBrowserSession
|
|
598
859
|
};
|
|
599
860
|
|
|
600
|
-
export { AgentBrowser, createAgentBrowser, createBrowserClient };
|
|
861
|
+
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.3.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": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
18
|
"build": "bunchee --runtime node",
|
|
19
|
+
"benchmark": "node --run build && node benchmarks/chromium.mjs",
|
|
19
20
|
"prepublishOnly": "node --run build",
|
|
20
21
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
21
22
|
"test": "node --run build && node --test tests/*.test.mjs",
|