remote-agent-browser 0.2.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -4
- package/dist/index.d.ts +26 -1
- package/dist/index.js +120 -9
- package/package.json +4 -8
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:
|
|
@@ -128,4 +153,24 @@ await writeFile('capture.har', result.file.bytes)
|
|
|
128
153
|
|
|
129
154
|
All methods use the same disposable browser session until `close()` is called.
|
|
130
155
|
|
|
156
|
+
### Keepalive
|
|
157
|
+
|
|
158
|
+
Keep the Sandbox alive across idle gaps during long-running work. Always stop
|
|
159
|
+
the heartbeat in a `finally` block; after it stops, the Sandbox expires at its
|
|
160
|
+
normal timeout unless it is closed earlier:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
const stopKeepalive = browser.keepalive()
|
|
164
|
+
try {
|
|
165
|
+
await runLongAgentTurn(browser)
|
|
166
|
+
} finally {
|
|
167
|
+
stopKeepalive()
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
By default, each heartbeat restores the wall-clock timeout configured by
|
|
172
|
+
`AgentBrowser.create()` and runs halfway through that window, capped at five
|
|
173
|
+
minutes. Override either value with `timeoutMs` and `intervalMs`. Renewal is
|
|
174
|
+
best-effort; pass `onError` to observe failures.
|
|
175
|
+
|
|
131
176
|
Container image and development details are in [docs.md](./docs.md).
|
package/dist/index.d.ts
CHANGED
|
@@ -60,13 +60,29 @@ type ExecOptions = {
|
|
|
60
60
|
type JsonExecOptions = Omit<ExecOptions, 'output'> & {
|
|
61
61
|
output: 'json';
|
|
62
62
|
};
|
|
63
|
+
type ProxyOptions = string | {
|
|
64
|
+
/** Proxy server URL, optionally including authentication. */
|
|
65
|
+
url: string;
|
|
66
|
+
/** Host patterns that should connect directly instead of using the proxy. */
|
|
67
|
+
bypass?: string | readonly string[];
|
|
68
|
+
};
|
|
63
69
|
type BrowserClientOptions = {
|
|
64
70
|
session?: string;
|
|
65
71
|
/** agent-browser CLI arguments inserted before every command. */
|
|
66
72
|
args?: string[];
|
|
73
|
+
/** Proxy configuration fixed for this client's lifetime. */
|
|
74
|
+
proxy?: ProxyOptions;
|
|
67
75
|
/** Whether close() also tears down the command runner. Default: true. */
|
|
68
76
|
ownsRunner?: boolean;
|
|
69
77
|
};
|
|
78
|
+
type KeepaliveOptions = {
|
|
79
|
+
/** Remaining sandbox lifetime to maintain. Defaults to its creation timeout. */
|
|
80
|
+
timeoutMs?: number;
|
|
81
|
+
/** Heartbeat cadence. Defaults to half the timeout, capped at 5 minutes. */
|
|
82
|
+
intervalMs?: number;
|
|
83
|
+
/** Optional observer for best-effort heartbeat failures. */
|
|
84
|
+
onError?: (error: unknown) => void;
|
|
85
|
+
};
|
|
70
86
|
/**
|
|
71
87
|
* Minimal command executor the browser client needs. Implemented by the
|
|
72
88
|
* Vercel Sandbox adapter; anything with the same shape can back a
|
|
@@ -88,6 +104,8 @@ interface CommandRunner {
|
|
|
88
104
|
writeFile(path: string, bytes: Buffer): Promise<void>;
|
|
89
105
|
/** Tear the remote environment down. */
|
|
90
106
|
close(): Promise<void>;
|
|
107
|
+
/** Keep the remote environment alive until the returned stop function runs. */
|
|
108
|
+
keepalive?(opts?: KeepaliveOptions): () => void;
|
|
91
109
|
}
|
|
92
110
|
/** Session-oriented remote browser client. */
|
|
93
111
|
interface AgentBrowser$1 {
|
|
@@ -115,6 +133,11 @@ interface AgentBrowser$1 {
|
|
|
115
133
|
png: Buffer;
|
|
116
134
|
result: BrowserRunResult;
|
|
117
135
|
}>;
|
|
136
|
+
/**
|
|
137
|
+
* Keep the backing Sandbox alive across idle gaps. Call the returned stop
|
|
138
|
+
* function in a finally block so the Sandbox can expire when work finishes.
|
|
139
|
+
*/
|
|
140
|
+
keepalive(opts?: KeepaliveOptions): () => void;
|
|
118
141
|
/** Close the browser session (and the sandbox when it owns it). */
|
|
119
142
|
close(): Promise<void>;
|
|
120
143
|
}
|
|
@@ -135,6 +158,8 @@ type CreateAgentBrowserOptions = {
|
|
|
135
158
|
env?: Record<string, string>;
|
|
136
159
|
/** agent-browser CLI arguments inserted before every command. */
|
|
137
160
|
args?: string[];
|
|
161
|
+
/** Proxy configuration fixed for this browser's lifetime. */
|
|
162
|
+
proxy?: ProxyOptions;
|
|
138
163
|
};
|
|
139
164
|
/**
|
|
140
165
|
* Create an agent-browser client running inside a fresh Vercel Sandbox.
|
|
@@ -177,4 +202,4 @@ declare const AgentBrowser: {
|
|
|
177
202
|
type AgentBrowser = AgentBrowser$1;
|
|
178
203
|
|
|
179
204
|
export { AgentBrowser, createAgentBrowser, createBrowserClient };
|
|
180
|
-
export type { BrowserClientOptions, BrowserCommandResult, BrowserRunResult, CommandRunner, CreateAgentBrowserOptions, ExecOptions, JsonExecOptions, RunOptions };
|
|
205
|
+
export type { BrowserClientOptions, BrowserCommandResult, BrowserRunResult, CommandRunner, CreateAgentBrowserOptions, ExecOptions, JsonExecOptions, KeepaliveOptions, ProxyOptions, RunOptions };
|
package/dist/index.js
CHANGED
|
@@ -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,6 +144,11 @@ 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;
|
|
115
153
|
const recordings = new Map();
|
|
116
154
|
let closed = false;
|
|
@@ -150,15 +188,25 @@ function outputSpec(args) {
|
|
|
150
188
|
remotePath = recording?.path;
|
|
151
189
|
fileSpec = recording?.spec;
|
|
152
190
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
191
|
+
let run;
|
|
192
|
+
try {
|
|
193
|
+
run = await runner.run('agent-browser', finalArgs, {
|
|
194
|
+
timeoutMs,
|
|
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
|
+
}
|
|
156
204
|
const result = {
|
|
157
205
|
args,
|
|
158
206
|
ok: run.exitCode === 0,
|
|
159
207
|
exitCode: run.exitCode,
|
|
160
|
-
stdout: run.stdout,
|
|
161
|
-
stderr: run.stderr
|
|
208
|
+
stdout: redactProxy(run.stdout),
|
|
209
|
+
stderr: redactProxy(run.stderr)
|
|
162
210
|
};
|
|
163
211
|
if (result.ok && startsRecording && remotePath && fileSpec) {
|
|
164
212
|
recordings.set(useSession, {
|
|
@@ -333,6 +381,13 @@ function outputSpec(args) {
|
|
|
333
381
|
result: run
|
|
334
382
|
};
|
|
335
383
|
},
|
|
384
|
+
keepalive (opts = {}) {
|
|
385
|
+
assertOpen();
|
|
386
|
+
if (!runner.keepalive) {
|
|
387
|
+
throw new Error('the command runner does not support keepalive');
|
|
388
|
+
}
|
|
389
|
+
return runner.keepalive(opts);
|
|
390
|
+
},
|
|
336
391
|
async close () {
|
|
337
392
|
if (closed) return;
|
|
338
393
|
closed = true;
|
|
@@ -340,7 +395,8 @@ function outputSpec(args) {
|
|
|
340
395
|
await runner.run('agent-browser', withSession(session, clientArgs, [
|
|
341
396
|
'close'
|
|
342
397
|
]), {
|
|
343
|
-
timeoutMs: 15_000
|
|
398
|
+
timeoutMs: 15_000,
|
|
399
|
+
env: clientEnv
|
|
344
400
|
}).catch(()=>{});
|
|
345
401
|
if (ownsRunner) await runner.close();
|
|
346
402
|
}
|
|
@@ -354,9 +410,11 @@ const BASE_ENV = {
|
|
|
354
410
|
AGENT_BROWSER_DEFAULT_TIMEOUT: '25000'
|
|
355
411
|
};
|
|
356
412
|
/** CommandRunner backed by one Vercel Sandbox. */ class SandboxRunner {
|
|
357
|
-
constructor(sandbox, env){
|
|
413
|
+
constructor(sandbox, env, timeoutMs = DEFAULT_TIMEOUT_MS){
|
|
414
|
+
this.keepaliveStops = new Set();
|
|
358
415
|
this.sandbox = sandbox;
|
|
359
416
|
this.env = env;
|
|
417
|
+
this.timeoutMs = timeoutMs;
|
|
360
418
|
}
|
|
361
419
|
async run(cmd, args, opts = {}) {
|
|
362
420
|
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
@@ -416,7 +474,58 @@ const BASE_ENV = {
|
|
|
416
474
|
}
|
|
417
475
|
]);
|
|
418
476
|
}
|
|
477
|
+
/** Restore a sliding idle window without cumulatively adding that window. */ async renewLifetime(timeoutMs) {
|
|
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
|
+
});
|
|
484
|
+
if (sandbox.status !== 'running') return;
|
|
485
|
+
this.sandbox = sandbox;
|
|
486
|
+
const targetExpiresAt = Date.now() + timeoutMs;
|
|
487
|
+
const currentExpiresAt = sandbox.expiresAt?.getTime();
|
|
488
|
+
const extensionMs = currentExpiresAt === undefined ? timeoutMs : Math.ceil(targetExpiresAt - currentExpiresAt);
|
|
489
|
+
if (extensionMs > 0) await sandbox.extendTimeout(extensionMs);
|
|
490
|
+
}
|
|
491
|
+
keepalive(opts = {}) {
|
|
492
|
+
const timeoutMs = opts.timeoutMs ?? this.timeoutMs;
|
|
493
|
+
const intervalMs = opts.intervalMs ?? Math.min(5 * 60 * 1000, Math.floor(timeoutMs / 2));
|
|
494
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
495
|
+
throw new RangeError('keepalive timeoutMs must be greater than 0');
|
|
496
|
+
}
|
|
497
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0 || intervalMs >= timeoutMs) {
|
|
498
|
+
throw new RangeError('keepalive intervalMs must be greater than 0 and less than timeoutMs');
|
|
499
|
+
}
|
|
500
|
+
let stopped = false;
|
|
501
|
+
let renewing = false;
|
|
502
|
+
const tick = async ()=>{
|
|
503
|
+
if (stopped || renewing) return;
|
|
504
|
+
renewing = true;
|
|
505
|
+
try {
|
|
506
|
+
await this.renewLifetime(timeoutMs);
|
|
507
|
+
} catch (error) {
|
|
508
|
+
opts.onError?.(error);
|
|
509
|
+
} finally{
|
|
510
|
+
renewing = false;
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
const timer = setInterval(()=>void tick(), intervalMs);
|
|
514
|
+
timer.unref?.();
|
|
515
|
+
void tick();
|
|
516
|
+
const stop = ()=>{
|
|
517
|
+
if (stopped) return;
|
|
518
|
+
stopped = true;
|
|
519
|
+
clearInterval(timer);
|
|
520
|
+
this.keepaliveStops.delete(stop);
|
|
521
|
+
};
|
|
522
|
+
this.keepaliveStops.add(stop);
|
|
523
|
+
return stop;
|
|
524
|
+
}
|
|
419
525
|
async close() {
|
|
526
|
+
for (const stop of [
|
|
527
|
+
...this.keepaliveStops
|
|
528
|
+
])stop();
|
|
420
529
|
await this.sandbox.stop().catch(()=>{});
|
|
421
530
|
}
|
|
422
531
|
}
|
|
@@ -432,17 +541,18 @@ const BASE_ENV = {
|
|
|
432
541
|
...BASE_ENV,
|
|
433
542
|
...opts.env
|
|
434
543
|
};
|
|
544
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
435
545
|
const sandbox = await Sandbox.create({
|
|
436
546
|
image,
|
|
437
547
|
resources: {
|
|
438
548
|
vcpus: opts.vcpus ?? 2
|
|
439
549
|
},
|
|
440
|
-
timeout:
|
|
550
|
+
timeout: timeoutMs,
|
|
441
551
|
ports: [],
|
|
442
552
|
env,
|
|
443
553
|
persistent: false
|
|
444
554
|
});
|
|
445
|
-
return new SandboxRunner(sandbox, env);
|
|
555
|
+
return new SandboxRunner(sandbox, env, timeoutMs);
|
|
446
556
|
}
|
|
447
557
|
|
|
448
558
|
/**
|
|
@@ -476,6 +586,7 @@ const BASE_ENV = {
|
|
|
476
586
|
return createBrowserClient(runner, {
|
|
477
587
|
session: opts.session,
|
|
478
588
|
args: opts.args,
|
|
589
|
+
proxy: opts.proxy,
|
|
479
590
|
ownsRunner: true
|
|
480
591
|
});
|
|
481
592
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "remote-agent-browser",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.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": {
|
|
@@ -8,22 +8,18 @@
|
|
|
8
8
|
"url": "git+https://github.com/vercel-labs/remote-agent-browser.git"
|
|
9
9
|
},
|
|
10
10
|
"type": "module",
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
11
13
|
"packageManager": "pnpm@11.18.0",
|
|
12
14
|
"publishConfig": {
|
|
13
15
|
"access": "public"
|
|
14
16
|
},
|
|
15
|
-
"exports": {
|
|
16
|
-
".": {
|
|
17
|
-
"types": "./dist/index.d.ts",
|
|
18
|
-
"import": "./dist/index.js"
|
|
19
|
-
}
|
|
20
|
-
},
|
|
21
17
|
"scripts": {
|
|
22
18
|
"build": "bunchee --runtime node",
|
|
23
19
|
"prepublishOnly": "node --run build",
|
|
24
20
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
25
21
|
"test": "node --run build && node --test tests/*.test.mjs",
|
|
26
|
-
"test:integration": "node --run build && node --test tests
|
|
22
|
+
"test:integration": "node --run build && node --test --test-concurrency=1 tests/*.integration.ts",
|
|
27
23
|
"test:unit": "node --run build && node --test tests/unit.test.mjs"
|
|
28
24
|
},
|
|
29
25
|
"files": [
|