@activeagents/browser-sidecar 0.0.0-stage → 1.9.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/LICENSE +21 -0
- package/README.md +227 -2
- package/bin/browser-sidecar.mjs +85 -0
- package/lib/config.mjs +191 -0
- package/lib/control-lock.mjs +210 -0
- package/lib/egress-proxy.mjs +127 -0
- package/lib/guard.mjs +86 -0
- package/lib/http-server.mjs +124 -0
- package/lib/input.mjs +137 -0
- package/lib/live-server.mjs +281 -0
- package/lib/live-ticket.mjs +126 -0
- package/lib/mcp-gateway.mjs +339 -0
- package/lib/navigation-guard.mjs +87 -0
- package/lib/network-policy.mjs +264 -0
- package/lib/recorder.mjs +348 -0
- package/lib/screencast.mjs +181 -0
- package/lib/sidecar.mjs +252 -0
- package/lib/storage-state.mjs +76 -0
- package/lib/version.mjs +5 -0
- package/package.json +36 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Active Agents AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,228 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @activeagents/browser-sidecar
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One sandbox's browser for the Active Agent dashboard (the `actionagent` gem).
|
|
4
|
+
It launches Chromium with a fresh profile, serves Playwright MCP to the
|
|
5
|
+
agents run against the sandbox, and records every page with rrweb into the
|
|
6
|
+
sandbox's session recording.
|
|
7
|
+
|
|
8
|
+
The dashboard starts and stops it; you do not run it by hand. On the `:local`
|
|
9
|
+
sandbox backend, install it once with:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bin/rails action_agent:browser:install # this package and its Chromium, under the local sandbox root
|
|
13
|
+
bin/rails action_agent:browser:doctor # checks Node, this package's version and Chromium
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Its version is always the engine's: the dashboard refuses a sidecar of any
|
|
17
|
+
other version.
|
|
18
|
+
|
|
19
|
+
## What it does
|
|
20
|
+
|
|
21
|
+
- **Browser.** Chromium through Playwright, headless or headed, with a new
|
|
22
|
+
temporary profile directory that is removed when it stops. Playwright drives
|
|
23
|
+
Chromium over a pipe (`--remote-debugging-pipe`), so no debugging port is
|
|
24
|
+
ever opened, and Chromium's own sandbox stays on unless the configuration
|
|
25
|
+
turns it off.
|
|
26
|
+
- **MCP.** Playwright MCP over HTTP at `POST /mcp`, one JSON-RPC message per
|
|
27
|
+
request and a JSON response, with sessions named by `Mcp-Session-Id`. The
|
|
28
|
+
tools are Playwright MCP's accessibility-snapshot tools, plus the optional
|
|
29
|
+
groups `testing`, `vision` and `pdf` when the start configuration asks for
|
|
30
|
+
them. `browser_run_code_unsafe`, which runs code in the sidecar's own
|
|
31
|
+
process, is never offered. A page snapshot an action writes to a file is
|
|
32
|
+
returned inline.
|
|
33
|
+
- **Network.** Chromium makes every connection through a forward proxy in
|
|
34
|
+
the sidecar, loopback included, and WebRTC may not send UDP around it. The
|
|
35
|
+
proxy resolves each host itself, refuses a loopback, link-local or private
|
|
36
|
+
address, or a name that resolves to one, and connects to the address it
|
|
37
|
+
checked. Only the app's own host and port are exempt. Redirects, frames
|
|
38
|
+
and names whose answer changes between lookups all pass through it.
|
|
39
|
+
Service workers are blocked.
|
|
40
|
+
- **Navigation.** A top-level navigation may only open the sandbox app's own
|
|
41
|
+
origin; a path such as `/login` is resolved against it. Chromium follows a
|
|
42
|
+
redirect without that check, so a tab a redirect takes to another origin
|
|
43
|
+
is closed as soon as it lands, and the tool call that led there returns an
|
|
44
|
+
error instead of its result.
|
|
45
|
+
- **Uploads.** Playwright MCP reads files to upload from the process's
|
|
46
|
+
working directory, which is an empty directory made for the session, and
|
|
47
|
+
from the sidecar's output directory, where its own snapshots, screenshots
|
|
48
|
+
and console logs are written.
|
|
49
|
+
- **Recording.** `@rrweb/record` runs in every page and frame, injected with
|
|
50
|
+
`addInitScript`, with input values and the text of `contenteditable`
|
|
51
|
+
elements masked before an event leaves the page.
|
|
52
|
+
Events reach the sidecar through a Playwright binding, so the app's CSP and
|
|
53
|
+
CORS do not apply, and are posted, gzipped, to the recording's ingest with
|
|
54
|
+
a token that can only write to that one recording. Console errors and
|
|
55
|
+
warnings, and markers for pages opening, navigating and closing, go with
|
|
56
|
+
them.
|
|
57
|
+
- **Live view.** With `live` in its configuration, the sidecar streams the
|
|
58
|
+
page on screen to the dashboard over a WebSocket at `/live`, and relays the
|
|
59
|
+
input of the one person who has taken control (see [The live
|
|
60
|
+
view](#the-live-view)).
|
|
61
|
+
|
|
62
|
+
## The HTTP surface
|
|
63
|
+
|
|
64
|
+
| Request | Answer |
|
|
65
|
+
|---------|--------|
|
|
66
|
+
| `GET /health` | `{ "status": "ok", "version": "…" }` |
|
|
67
|
+
| `POST /mcp` | one JSON-RPC message, answered as JSON |
|
|
68
|
+
| `DELETE /mcp` | ends the session in `Mcp-Session-Id` |
|
|
69
|
+
| `GET /live` | the live view's WebSocket, when `live` is configured |
|
|
70
|
+
| `GET /storage-state` | `{ "storage_state": { "cookies": […], "origins": […] } }`: the browser's cookies and localStorage for the app, so a sign-in made in it can be kept |
|
|
71
|
+
|
|
72
|
+
Every request, a WebSocket upgrade other than the live view's included, must:
|
|
73
|
+
|
|
74
|
+
- carry `Authorization: Bearer <token>`;
|
|
75
|
+
- name an accepted `Host`: the listening address and port, the loopback names
|
|
76
|
+
when listening on loopback or on every interface, and `allowed_hosts`;
|
|
77
|
+
- carry no `Origin` header. A page in a browser always sends one, so a page
|
|
78
|
+
the browser visits cannot reach the sidecar, through a loopback request or
|
|
79
|
+
DNS rebinding.
|
|
80
|
+
|
|
81
|
+
No CORS header is ever sent.
|
|
82
|
+
|
|
83
|
+
## The live view
|
|
84
|
+
|
|
85
|
+
The dashboard's page opens `ws://<host>:<port>/live` itself, so the upgrade
|
|
86
|
+
carries the dashboard's `Origin` and no token. It is accepted only when it
|
|
87
|
+
names an accepted `Host`, its `Origin` is one of `live.origins`, and its URL
|
|
88
|
+
has no query. The first message must then be a ticket:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{ "type": "auth", "ticket": "<payload>.<signature>" }
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The dashboard issues a ticket per viewer (`POST
|
|
95
|
+
/api/sandboxes/:id/browser/tickets`). Its payload is base64url JSON
|
|
96
|
+
`{ v: 1, sid, sub, name, mode, iat, exp, jti }`, and its signature is the
|
|
97
|
+
base64url HMAC-SHA256 of the payload under a key both sides derive from the
|
|
98
|
+
browser token: HMAC-SHA256 of `activeagents/browser-live-ticket/v1` keyed
|
|
99
|
+
with the token. The sidecar accepts a ticket once, for at most 60 seconds,
|
|
100
|
+
for this sandbox (`sid` is `live.session_id`) and for `mode` `view` or
|
|
101
|
+
`control`. A connection whose first message is anything else is closed
|
|
102
|
+
(code 4401) before anything is sent to it, and one that sends nothing for 5
|
|
103
|
+
seconds is closed with 4408.
|
|
104
|
+
|
|
105
|
+
Once accepted, the viewer is sent `ready`, the last frame, and every frame
|
|
106
|
+
after it: `{ type: "frame", data, width, height }`, a JPEG as base64 with the
|
|
107
|
+
page's size in CSS pixels. The stream follows the page on screen: the newest
|
|
108
|
+
tab, or the one the agent selected with `browser_tabs`. A still page sends no
|
|
109
|
+
frames, so the last one is kept for whoever joins later. `control`, `agent`
|
|
110
|
+
and `page` messages tell of who holds control, an agent call waiting for it,
|
|
111
|
+
and the page on screen. A `control` message's `mine` says whether this
|
|
112
|
+
connection holds control, and `yours` whether its user does on any
|
|
113
|
+
connection, as after a reload while control waits out the grace period.
|
|
114
|
+
|
|
115
|
+
Either mode of ticket lets a connection watch. To take control it sends
|
|
116
|
+
`{ type: "take_control", ticket }` with a control ticket of its own, issued
|
|
117
|
+
to the user it connected as, each time it asks. Withdrawing a person's
|
|
118
|
+
permission therefore stops them taking control again, but does not take it
|
|
119
|
+
from a connection that holds it. `{ type: "hand_back" }` gives control back.
|
|
120
|
+
One person holds control at a time; their other connections may move it
|
|
121
|
+
between them. While they hold it:
|
|
122
|
+
|
|
123
|
+
- their `mouse`, `wheel`, `key` and `text` messages reach the page through
|
|
124
|
+
`Input.dispatchMouseEvent`, `Input.dispatchKeyEvent` and `Input.insertText`.
|
|
125
|
+
Pointer positions are fractions of the frame, scaled to the page. Nobody
|
|
126
|
+
else's input is relayed;
|
|
127
|
+
- an agent's tool call that would change the page waits for them to hand
|
|
128
|
+
back, up to `live.agent_wait_ms`. If they have not, it returns an error
|
|
129
|
+
naming them, with `_meta["activeagents/takeover"]`:
|
|
130
|
+
`{ held_by, since, waited_ms }`. A call still waiting when the browser
|
|
131
|
+
stops returns an error saying it is stopping. Calls that only read the
|
|
132
|
+
page, such as `browser_snapshot`, go ahead;
|
|
133
|
+
- if their connection drops, control is released after
|
|
134
|
+
`live.release_grace_ms`.
|
|
135
|
+
|
|
136
|
+
Relayed input is never logged or recorded. Taking control and handing it
|
|
137
|
+
back are recorded as `marker` events (`takeover_started`, `takeover_ended`,
|
|
138
|
+
with `source: "human"` and who), and what the person changed shows in the
|
|
139
|
+
masked rrweb stream.
|
|
140
|
+
|
|
141
|
+
## The command
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
activeagents-browser-sidecar serve [--config-file PATH]
|
|
145
|
+
activeagents-browser-sidecar check
|
|
146
|
+
activeagents-browser-sidecar install-browser [playwright install options]
|
|
147
|
+
activeagents-browser-sidecar --version
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`serve` reads its configuration from stdin, or from `--config-file`, so the
|
|
151
|
+
token never appears in a process listing or an environment. Once it listens
|
|
152
|
+
it prints one line to stdout, `{"ready":true,"port":…,"version":"…","pid":…}`,
|
|
153
|
+
and logs only to stderr. SIGTERM, SIGINT or SIGHUP stops it: it closes the
|
|
154
|
+
browser, posts what it has recorded, and removes its directories.
|
|
155
|
+
|
|
156
|
+
`check` prints `{ "version", "node", "chromium": { "installed", "executable" } }`.
|
|
157
|
+
`install-browser` installs the Chromium this version drives, into
|
|
158
|
+
`PLAYWRIGHT_BROWSERS_PATH` when that is set.
|
|
159
|
+
|
|
160
|
+
## Configuration
|
|
161
|
+
|
|
162
|
+
```json
|
|
163
|
+
{
|
|
164
|
+
"token": "…",
|
|
165
|
+
"app_url": "http://127.0.0.1:3000",
|
|
166
|
+
"mode": "headless",
|
|
167
|
+
"capabilities": [],
|
|
168
|
+
"host": "127.0.0.1",
|
|
169
|
+
"port": 0,
|
|
170
|
+
"allowed_hosts": [],
|
|
171
|
+
"workdir": "/path/to/a/directory",
|
|
172
|
+
"stop_at": 1767225600000,
|
|
173
|
+
"chromium_sandbox": true,
|
|
174
|
+
"recording": {
|
|
175
|
+
"url": "http://127.0.0.1:3000/activeagents/api/session_recordings/7/events",
|
|
176
|
+
"token": "aarec_…",
|
|
177
|
+
"batch_events": 1000,
|
|
178
|
+
"batch_bytes": 1048576
|
|
179
|
+
},
|
|
180
|
+
"live": {
|
|
181
|
+
"session_id": "…",
|
|
182
|
+
"origins": ["http://localhost:3000"],
|
|
183
|
+
"agent_wait_ms": 20000,
|
|
184
|
+
"release_grace_ms": 10000
|
|
185
|
+
},
|
|
186
|
+
"storage_state": { "cookies": [], "origins": [] }
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
| Key | Meaning |
|
|
191
|
+
|-----|---------|
|
|
192
|
+
| `token` | the bearer token every request must carry (at least 32 characters) |
|
|
193
|
+
| `app_url` | the sandbox app; its origin is the only one a page may be opened on |
|
|
194
|
+
| `mode` | `headless`, or `headed` for a visible window |
|
|
195
|
+
| `capabilities` | optional Playwright MCP tool groups: `testing`, `vision`, `pdf` |
|
|
196
|
+
| `host`, `port` | where to listen; port `0` picks a free one |
|
|
197
|
+
| `allowed_hosts` | `Host` values to accept besides the listening address, such as a container name with its port |
|
|
198
|
+
| `workdir` | where the profile, uploads and output directories are made; a temporary directory when unset |
|
|
199
|
+
| `stop_at` | when to stop on its own, as epoch milliseconds or ISO 8601 |
|
|
200
|
+
| `chromium_sandbox` | `false` where Chromium's own sandbox cannot run, as in a container without the privileges it needs |
|
|
201
|
+
| `recording` | where to post recorded events, and the batch limits the ingest enforces; nothing is recorded when unset |
|
|
202
|
+
| `live` | the live view: the sandbox session tickets must name, the dashboard origins that may open it, how long an agent's call waits for control (at most 45000 ms) and how long control outlives a dropped connection; no live view when unset |
|
|
203
|
+
| `storage_state` | a Playwright storage state the browser starts with, such as a sign-in saved from `GET /storage-state`; only the cookies sent to the app's host and the localStorage of its origin are applied |
|
|
204
|
+
|
|
205
|
+
## The image
|
|
206
|
+
|
|
207
|
+
The `Dockerfile` here builds the same package with its Chromium, running as a
|
|
208
|
+
non-root user. A host that runs each sandbox's browser as a container starts
|
|
209
|
+
it with the configuration on stdin (`docker run -i`) or in a file:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
docker run -i --rm -p 8931:8931 ghcr.io/activeagents/browser-sidecar:<version> < config.json
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
with `"host": "0.0.0.0"`, `"port": 8931`, and the name the dashboard reaches it
|
|
216
|
+
by in `allowed_hosts`.
|
|
217
|
+
|
|
218
|
+
## Developing
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
npm ci
|
|
222
|
+
npm test # the real-browser test is skipped without Chromium
|
|
223
|
+
npx playwright install chromium && npm test # runs it too
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
To have the dashboard run a checkout of this directory instead of the
|
|
227
|
+
installed package, set `ActionAgent.browser_sidecar_path` to it. The version
|
|
228
|
+
check is skipped for a checkout.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// activeagents-browser-sidecar [serve [--config-file PATH] | check | install-browser [ARGS...] | --version]
|
|
3
|
+
//
|
|
4
|
+
// serve reads the start configuration (lib/config.mjs) from stdin, or
|
|
5
|
+
// from --config-file, starts the browser, and prints one JSON line
|
|
6
|
+
// to stdout once it listens: { ready, port, version, pid }. Logs go
|
|
7
|
+
// to stderr. SIGTERM or SIGINT stops it cleanly.
|
|
8
|
+
// check prints { version, node, chromium: { installed, executable } }
|
|
9
|
+
// install-browser installs the Chromium this sidecar drives (Playwright's installer)
|
|
10
|
+
// --version prints the version
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { ConfigError, parseConfig } from '../lib/config.mjs';
|
|
17
|
+
import { VERSION } from '../lib/version.mjs';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
|
|
21
|
+
function readStdin() {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
|
25
|
+
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
26
|
+
process.stdin.on('error', reject);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function check() {
|
|
31
|
+
const { chromium } = await import('playwright');
|
|
32
|
+
const executable = chromium.executablePath();
|
|
33
|
+
const installed = Boolean(executable) && existsSync(executable);
|
|
34
|
+
process.stdout.write(`${JSON.stringify({ version: VERSION, node: process.version, chromium: { installed, executable } })}\n`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function installBrowser(args) {
|
|
38
|
+
const cli = join(dirname(require.resolve('playwright/package.json')), 'cli.js');
|
|
39
|
+
const result = spawnSync(process.execPath, [cli, 'install', 'chromium', ...args], { stdio: 'inherit' });
|
|
40
|
+
process.exit(result.status ?? 1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function serve(args) {
|
|
44
|
+
const fileIndex = args.indexOf('--config-file');
|
|
45
|
+
const source = fileIndex === -1 ? await readStdin() : readFileSync(args[fileIndex + 1], 'utf8');
|
|
46
|
+
const config = parseConfig(source);
|
|
47
|
+
|
|
48
|
+
// Only the ready line goes to stdout; whoever started the sidecar may stop
|
|
49
|
+
// reading it after that.
|
|
50
|
+
console.log = console.error;
|
|
51
|
+
const { startSidecar } = await import('../lib/sidecar.mjs');
|
|
52
|
+
const sidecar = await startSidecar(config);
|
|
53
|
+
process.stdout.write(`${JSON.stringify({ ready: true, port: sidecar.port, version: VERSION, pid: process.pid })}\n`);
|
|
54
|
+
|
|
55
|
+
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
|
|
56
|
+
process.once(signal, () => void sidecar.close(`received ${signal}`));
|
|
57
|
+
}
|
|
58
|
+
sidecar.done.then((status) => process.exit(status));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const [command = 'serve', ...rest] = process.argv.slice(2);
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
switch (command) {
|
|
65
|
+
case '--version':
|
|
66
|
+
case 'version':
|
|
67
|
+
process.stdout.write(`${VERSION}\n`);
|
|
68
|
+
break;
|
|
69
|
+
case 'check':
|
|
70
|
+
await check();
|
|
71
|
+
break;
|
|
72
|
+
case 'install-browser':
|
|
73
|
+
installBrowser(rest);
|
|
74
|
+
break;
|
|
75
|
+
case 'serve':
|
|
76
|
+
await serve(rest);
|
|
77
|
+
break;
|
|
78
|
+
default:
|
|
79
|
+
process.stderr.write('usage: activeagents-browser-sidecar [serve [--config-file PATH] | check | install-browser | --version]\n');
|
|
80
|
+
process.exit(2);
|
|
81
|
+
}
|
|
82
|
+
} catch (error) {
|
|
83
|
+
process.stderr.write(`browser-sidecar: ${error instanceof ConfigError ? `invalid configuration: ${error.message}` : error.stack ?? error}\n`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// The start configuration, read as JSON from stdin (or a file the image is
|
|
2
|
+
// given), so the bearer token never appears in argv or the environment:
|
|
3
|
+
//
|
|
4
|
+
// {
|
|
5
|
+
// "token": "...", bearer token every request must carry
|
|
6
|
+
// "app_url": "http://127.0.0.1:3000", the sandbox app; top-level navigation stays on its origin
|
|
7
|
+
// "mode": "headless" | "headed",
|
|
8
|
+
// "capabilities": ["testing"], optional Playwright MCP tool groups
|
|
9
|
+
// "host": "127.0.0.1", "port": 0, where the MCP endpoint listens (0 picks a free port)
|
|
10
|
+
// "allowed_hosts": ["browser:8931"], Host header values accepted besides the listening address
|
|
11
|
+
// "workdir": "/path", where the profile, uploads and output directories go
|
|
12
|
+
// "stop_at": 1767225600000, when to shut down on its own (epoch ms or ISO 8601)
|
|
13
|
+
// "chromium_sandbox": true, false where Chromium's own sandbox cannot run (some containers)
|
|
14
|
+
// "recording": { "url": "...", "token": "...", "batch_events": 1000, "batch_bytes": 1048576 },
|
|
15
|
+
// "live": { "session_id": "...", "origins": ["http://localhost:3000"],
|
|
16
|
+
// "agent_wait_ms": 20000, "release_grace_ms": 10000 }
|
|
17
|
+
// "storage_state": { "cookies": [...], "origins": [...] } a saved sign-in the browser starts with
|
|
18
|
+
// }
|
|
19
|
+
|
|
20
|
+
import { appStorageState, StorageStateError } from './storage-state.mjs';
|
|
21
|
+
|
|
22
|
+
export const MODES = ['headless', 'headed'];
|
|
23
|
+
// Tool groups a browser may be started with. The others reach outside the
|
|
24
|
+
// page: devtools writes traces and videos, storage reads and writes storage
|
|
25
|
+
// state files, network rewrites responses, config describes the server.
|
|
26
|
+
export const CAPABILITIES = ['testing', 'vision', 'pdf'];
|
|
27
|
+
export const MIN_TOKEN_LENGTH = 32;
|
|
28
|
+
const DEFAULT_BATCH_EVENTS = 1000;
|
|
29
|
+
const DEFAULT_BATCH_BYTES = 1024 * 1024;
|
|
30
|
+
// How long an agent's browser call waits for a person to hand control back
|
|
31
|
+
// before it is refused. The dashboard gives up on a tool call after 60
|
|
32
|
+
// seconds, so the wait ends well before that.
|
|
33
|
+
const DEFAULT_AGENT_WAIT_MS = 20_000;
|
|
34
|
+
export const MAX_AGENT_WAIT_MS = 45_000;
|
|
35
|
+
const DEFAULT_RELEASE_GRACE_MS = 10_000;
|
|
36
|
+
|
|
37
|
+
export class ConfigError extends Error {}
|
|
38
|
+
|
|
39
|
+
function fail(message) {
|
|
40
|
+
throw new ConfigError(message);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function httpUrl(value, name) {
|
|
44
|
+
let url;
|
|
45
|
+
try {
|
|
46
|
+
url = new URL(value);
|
|
47
|
+
} catch {
|
|
48
|
+
fail(`${name} must be an absolute http(s) URL`);
|
|
49
|
+
}
|
|
50
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') fail(`${name} must be an absolute http(s) URL`);
|
|
51
|
+
if (url.username || url.password) fail(`${name} must not carry credentials`);
|
|
52
|
+
return url;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function token(value, name) {
|
|
56
|
+
if (typeof value !== 'string' || value.length < MIN_TOKEN_LENGTH || /\s/.test(value)) {
|
|
57
|
+
fail(`${name} must be a string of at least ${MIN_TOKEN_LENGTH} characters without whitespace`);
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function positiveInteger(value, name, fallback) {
|
|
63
|
+
if (value === undefined || value === null) return fallback;
|
|
64
|
+
if (!Number.isInteger(value) || value <= 0) fail(`${name} must be a positive integer`);
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function stopAt(value) {
|
|
69
|
+
if (value === undefined || value === null) return null;
|
|
70
|
+
const time = typeof value === 'number' ? value : Date.parse(value);
|
|
71
|
+
if (!Number.isFinite(time)) fail('stop_at must be epoch milliseconds or an ISO 8601 time');
|
|
72
|
+
return time;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function recording(value) {
|
|
76
|
+
if (value === undefined || value === null) return null;
|
|
77
|
+
if (typeof value !== 'object' || Array.isArray(value)) fail('recording must be an object');
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
url: httpUrl(value.url, 'recording.url').toString(),
|
|
81
|
+
token: token(value.token, 'recording.token'),
|
|
82
|
+
batchEvents: positiveInteger(value.batch_events, 'recording.batch_events', DEFAULT_BATCH_EVENTS),
|
|
83
|
+
batchBytes: positiveInteger(value.batch_bytes, 'recording.batch_bytes', DEFAULT_BATCH_BYTES),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function nonNegativeInteger(value, name, fallback, max) {
|
|
88
|
+
if (value === undefined || value === null) return fallback;
|
|
89
|
+
if (!Number.isInteger(value) || value < 0 || value > max) fail(`${name} must be an integer from 0 to ${max}`);
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Returns `value` as the Origin header a browser sends for it:
|
|
94
|
+
// "HTTP://127.1:3000/" is "http://127.0.0.1:3000".
|
|
95
|
+
function origin(value) {
|
|
96
|
+
let url;
|
|
97
|
+
try {
|
|
98
|
+
url = new URL(value);
|
|
99
|
+
} catch {
|
|
100
|
+
fail('live.origins must be a list of http(s) origins');
|
|
101
|
+
}
|
|
102
|
+
const bare = url.pathname === '/' && url.search === '' && url.hash === '' && url.username === '' && url.password === '';
|
|
103
|
+
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !bare) {
|
|
104
|
+
fail('live.origins must be a list of http(s) origins, such as http://localhost:3000');
|
|
105
|
+
}
|
|
106
|
+
return url.origin;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function live(value) {
|
|
110
|
+
if (value === undefined || value === null) return null;
|
|
111
|
+
if (typeof value !== 'object' || Array.isArray(value)) fail('live must be an object');
|
|
112
|
+
if (typeof value.session_id !== 'string' || value.session_id === '') fail('live.session_id must name the sandbox session');
|
|
113
|
+
if (!Array.isArray(value.origins) || value.origins.length === 0 || value.origins.some((entry) => typeof entry !== 'string')) {
|
|
114
|
+
fail('live.origins must be a list of http(s) origins');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
sessionId: value.session_id,
|
|
119
|
+
origins: [...new Set(value.origins.map(origin))],
|
|
120
|
+
agentWaitMs: nonNegativeInteger(value.agent_wait_ms, 'live.agent_wait_ms', DEFAULT_AGENT_WAIT_MS, MAX_AGENT_WAIT_MS),
|
|
121
|
+
releaseGraceMs: positiveInteger(value.release_grace_ms, 'live.release_grace_ms', DEFAULT_RELEASE_GRACE_MS),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Validates a start configuration and returns it with camelCase keys.
|
|
127
|
+
*
|
|
128
|
+
* @param {string|object} input the configuration, as JSON text or parsed
|
|
129
|
+
* @returns {object}
|
|
130
|
+
* @throws {ConfigError} naming the first invalid setting
|
|
131
|
+
*/
|
|
132
|
+
export function parseConfig(input) {
|
|
133
|
+
let raw = input;
|
|
134
|
+
if (typeof input === 'string') {
|
|
135
|
+
try {
|
|
136
|
+
raw = JSON.parse(input);
|
|
137
|
+
} catch {
|
|
138
|
+
fail('the configuration is not JSON');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) fail('the configuration must be a JSON object');
|
|
142
|
+
|
|
143
|
+
const mode = raw.mode ?? 'headless';
|
|
144
|
+
if (!MODES.includes(mode)) fail(`mode must be one of ${MODES.join(', ')}`);
|
|
145
|
+
|
|
146
|
+
const capabilities = raw.capabilities ?? [];
|
|
147
|
+
if (!Array.isArray(capabilities) || capabilities.some((name) => !CAPABILITIES.includes(name))) {
|
|
148
|
+
fail(`capabilities may only name ${CAPABILITIES.join(', ')}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const allowedHosts = raw.allowed_hosts ?? [];
|
|
152
|
+
if (!Array.isArray(allowedHosts) || allowedHosts.some((host) => typeof host !== 'string' || host === '')) {
|
|
153
|
+
fail('allowed_hosts must be a list of host names');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const port = raw.port ?? 0;
|
|
157
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) fail('port must be an integer from 0 to 65535');
|
|
158
|
+
|
|
159
|
+
if (raw.chromium_sandbox !== undefined && typeof raw.chromium_sandbox !== 'boolean') fail('chromium_sandbox must be true or false');
|
|
160
|
+
|
|
161
|
+
if (raw.workdir !== undefined && raw.workdir !== null && (typeof raw.workdir !== 'string' || raw.workdir === '')) {
|
|
162
|
+
fail('workdir must be a directory path');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const appOrigin = httpUrl(raw.app_url, 'app_url').origin;
|
|
166
|
+
let storageState = null;
|
|
167
|
+
if (raw.storage_state !== undefined && raw.storage_state !== null) {
|
|
168
|
+
try {
|
|
169
|
+
storageState = appStorageState(raw.storage_state, appOrigin);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (error instanceof StorageStateError) fail(error.message);
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
token: token(raw.token, 'token'),
|
|
178
|
+
appOrigin,
|
|
179
|
+
mode,
|
|
180
|
+
capabilities: [...new Set(capabilities)],
|
|
181
|
+
host: typeof raw.host === 'string' && raw.host !== '' ? raw.host : '127.0.0.1',
|
|
182
|
+
port,
|
|
183
|
+
allowedHosts: allowedHosts.map((host) => host.toLowerCase()),
|
|
184
|
+
workdir: raw.workdir ?? null,
|
|
185
|
+
chromiumSandbox: raw.chromium_sandbox !== false,
|
|
186
|
+
stopAt: stopAt(raw.stop_at),
|
|
187
|
+
recording: recording(raw.recording),
|
|
188
|
+
live: live(raw.live),
|
|
189
|
+
storageState,
|
|
190
|
+
};
|
|
191
|
+
}
|