@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 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
- # Temporary Holding Version
1
+ # @activeagents/browser-sidecar
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ }