remote-agent-browser 1.1.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 +65 -0
- package/dist/index.d.ts +49 -2
- package/dist/index.js +258 -36
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -102,6 +102,25 @@ await browser.exec('find', {
|
|
|
102
102
|
})
|
|
103
103
|
```
|
|
104
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
|
+
|
|
105
124
|
### Typed JSON
|
|
106
125
|
|
|
107
126
|
Set `output: 'json'` for commands that support `--json`. `exec<T>()` adds the
|
|
@@ -143,6 +162,17 @@ const result = await browser.exec('network', { args: ['har', 'stop'] })
|
|
|
143
162
|
await writeFile('capture.har', result.file.bytes)
|
|
144
163
|
```
|
|
145
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
|
+
|
|
146
176
|
### Convenience methods
|
|
147
177
|
|
|
148
178
|
- `browser.snapshot(url?)` optionally opens a page, then returns its interactive
|
|
@@ -153,6 +183,41 @@ await writeFile('capture.har', result.file.bytes)
|
|
|
153
183
|
|
|
154
184
|
All methods use the same disposable browser session until `close()` is called.
|
|
155
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
|
+
|
|
156
221
|
### Keepalive
|
|
157
222
|
|
|
158
223
|
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,6 +165,15 @@ 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
|
+
}
|
|
144
177
|
|
|
145
178
|
type CreateAgentBrowserOptions = {
|
|
146
179
|
/** Named CLI session; state (page, cookies, refs) persists across calls. */
|
|
@@ -161,6 +194,10 @@ type CreateAgentBrowserOptions = {
|
|
|
161
194
|
/** Proxy configuration fixed for this browser's lifetime. */
|
|
162
195
|
proxy?: ProxyOptions;
|
|
163
196
|
};
|
|
197
|
+
type CreateAgentBrowserSessionOptions = CreateAgentBrowserOptions & {
|
|
198
|
+
/** Stable logical identity used to find this browser across processes. */
|
|
199
|
+
id: string;
|
|
200
|
+
};
|
|
164
201
|
/**
|
|
165
202
|
* Create an agent-browser client running inside a fresh Vercel Sandbox.
|
|
166
203
|
*
|
|
@@ -184,6 +221,15 @@ type CreateAgentBrowserOptions = {
|
|
|
184
221
|
* ```
|
|
185
222
|
*/
|
|
186
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;
|
|
187
233
|
|
|
188
234
|
/**
|
|
189
235
|
* Create a session-oriented browser client over any CommandRunner.
|
|
@@ -198,8 +244,9 @@ declare function createBrowserClient(runner: CommandRunner, opts?: BrowserClient
|
|
|
198
244
|
/** Create AgentBrowser clients backed by a fresh Vercel Sandbox. */
|
|
199
245
|
declare const AgentBrowser: {
|
|
200
246
|
create: typeof createAgentBrowser;
|
|
247
|
+
session: typeof createAgentBrowserSession;
|
|
201
248
|
};
|
|
202
249
|
type AgentBrowser = AgentBrowser$1;
|
|
203
250
|
|
|
204
|
-
export { AgentBrowser, createAgentBrowser, createBrowserClient };
|
|
205
|
-
export type { BrowserClientOptions, BrowserCommandResult, BrowserRunResult, CommandRunner, CreateAgentBrowserOptions, ExecOptions, JsonExecOptions, KeepaliveOptions, ProxyOptions, 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([
|
|
@@ -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
|
};
|
|
@@ -416,9 +459,34 @@ const BASE_ENV = {
|
|
|
416
459
|
this.env = env;
|
|
417
460
|
this.timeoutMs = timeoutMs;
|
|
418
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(()=>{});
|
|
477
|
+
}
|
|
419
478
|
async run(cmd, args, opts = {}) {
|
|
420
479
|
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
421
|
-
|
|
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({
|
|
422
490
|
cmd,
|
|
423
491
|
args,
|
|
424
492
|
env: {
|
|
@@ -428,8 +496,12 @@ const BASE_ENV = {
|
|
|
428
496
|
detached: true
|
|
429
497
|
});
|
|
430
498
|
let timer;
|
|
499
|
+
let timedOut = false;
|
|
431
500
|
const timeout = new Promise((_, reject)=>{
|
|
432
|
-
timer = setTimeout(()=>
|
|
501
|
+
timer = setTimeout(()=>{
|
|
502
|
+
timedOut = true;
|
|
503
|
+
reject(new Error(`command timed out after ${timeoutMs}ms`));
|
|
504
|
+
}, timeoutMs);
|
|
433
505
|
if (typeof timer.unref === 'function') timer.unref();
|
|
434
506
|
});
|
|
435
507
|
try {
|
|
@@ -449,11 +521,38 @@ const BASE_ENV = {
|
|
|
449
521
|
};
|
|
450
522
|
} catch (error) {
|
|
451
523
|
await command.kill().catch(()=>{});
|
|
524
|
+
if (timedOut) await this.reclaimBrowser(sandbox);
|
|
452
525
|
throw error;
|
|
453
526
|
} finally{
|
|
454
527
|
if (timer) clearTimeout(timer);
|
|
455
528
|
}
|
|
456
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
|
+
}
|
|
457
556
|
async readFile(path) {
|
|
458
557
|
const { stdout, exitCode, stderr } = await this.run('base64', [
|
|
459
558
|
'-w0',
|
|
@@ -467,27 +566,28 @@ const BASE_ENV = {
|
|
|
467
566
|
return Buffer.from(stdout.trim(), 'base64');
|
|
468
567
|
}
|
|
469
568
|
async writeFile(path, bytes) {
|
|
470
|
-
await this.
|
|
569
|
+
const sandbox = await this.acquire();
|
|
570
|
+
await sandbox.writeFiles([
|
|
471
571
|
{
|
|
472
572
|
path,
|
|
473
573
|
content: bytes
|
|
474
574
|
}
|
|
475
575
|
]);
|
|
476
576
|
}
|
|
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
|
-
});
|
|
577
|
+
/** Restore a sliding idle window without cumulatively adding that window. */ async restoreLifetime(sandbox, timeoutMs) {
|
|
484
578
|
if (sandbox.status !== 'running') return;
|
|
485
|
-
this.sandbox = sandbox;
|
|
486
579
|
const targetExpiresAt = Date.now() + timeoutMs;
|
|
487
580
|
const currentExpiresAt = sandbox.expiresAt?.getTime();
|
|
488
581
|
const extensionMs = currentExpiresAt === undefined ? timeoutMs : Math.ceil(targetExpiresAt - currentExpiresAt);
|
|
489
582
|
if (extensionMs > 0) await sandbox.extendTimeout(extensionMs);
|
|
490
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
|
+
}
|
|
491
591
|
keepalive(opts = {}) {
|
|
492
592
|
const timeoutMs = opts.timeoutMs ?? this.timeoutMs;
|
|
493
593
|
const intervalMs = opts.intervalMs ?? Math.min(5 * 60 * 1000, Math.floor(timeoutMs / 2));
|
|
@@ -526,8 +626,95 @@ const BASE_ENV = {
|
|
|
526
626
|
for (const stop of [
|
|
527
627
|
...this.keepaliveStops
|
|
528
628
|
])stop();
|
|
529
|
-
await this.
|
|
629
|
+
await this.destroyRuntime();
|
|
630
|
+
}
|
|
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
|
+
}
|
|
530
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);
|
|
531
718
|
}
|
|
532
719
|
/**
|
|
533
720
|
* Start an ephemeral Vercel Sandbox from the pre-built browser image.
|
|
@@ -590,11 +777,46 @@ const BASE_ENV = {
|
|
|
590
777
|
ownsRunner: true
|
|
591
778
|
});
|
|
592
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,
|
|
801
|
+
ownsRunner: true
|
|
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
|
+
};
|
|
813
|
+
}
|
|
593
814
|
|
|
594
815
|
// Public library surface: create an agent browser in a Vercel Sandbox and
|
|
595
816
|
// drive it with the agent-browser CLI.
|
|
596
817
|
/** Create AgentBrowser clients backed by a fresh Vercel Sandbox. */ const AgentBrowser = {
|
|
597
|
-
create: createAgentBrowser
|
|
818
|
+
create: createAgentBrowser,
|
|
819
|
+
session: createAgentBrowserSession
|
|
598
820
|
};
|
|
599
821
|
|
|
600
|
-
export { AgentBrowser, createAgentBrowser, createBrowserClient };
|
|
822
|
+
export { AgentBrowser, createAgentBrowser, createAgentBrowserSession, createBrowserClient };
|