@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/lib/sidecar.mjs
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { mkdtemp, realpath, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { ControlLock } from './control-lock.mjs';
|
|
6
|
+
import { startEgressProxy } from './egress-proxy.mjs';
|
|
7
|
+
import { acceptedHosts } from './guard.mjs';
|
|
8
|
+
import { createHttpServer } from './http-server.mjs';
|
|
9
|
+
import { LiveServer } from './live-server.mjs';
|
|
10
|
+
import { TicketVerifier, ticketKey } from './live-ticket.mjs';
|
|
11
|
+
import { McpGateway } from './mcp-gateway.mjs';
|
|
12
|
+
import { NavigationGuard } from './navigation-guard.mjs';
|
|
13
|
+
import { NetworkPolicy } from './network-policy.mjs';
|
|
14
|
+
import { PAGE_FLUSH_MS, Recorder } from './recorder.mjs';
|
|
15
|
+
import { Screencast } from './screencast.mjs';
|
|
16
|
+
import { exportStorageState } from './storage-state.mjs';
|
|
17
|
+
import { VERSION } from './version.mjs';
|
|
18
|
+
|
|
19
|
+
const VIEWPORT = { width: 1280, height: 800 };
|
|
20
|
+
// WebRTC sends UDP around the proxy unless told otherwise. The headless
|
|
21
|
+
// shell reads the first switch and headed Chromium the second.
|
|
22
|
+
const WEBRTC_PROXIED_ONLY = ['--force-webrtc-ip-handling-policy=disable_non_proxied_udp', '--webrtc-ip-handling-policy=disable_non_proxied_udp'];
|
|
23
|
+
// How long shutting down waits for the last recording batches to post.
|
|
24
|
+
const RECORDING_DRAIN_MS = 5000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Checks every request and WebSocket the context's pages open against
|
|
28
|
+
* `policy.allows`, by URL, before it is sent. Registered once on the
|
|
29
|
+
* context, so it holds whichever MCP session drives the browser. Playwright
|
|
30
|
+
* calls a route only for the first URL of a redirect chain; the egress proxy
|
|
31
|
+
* and the NavigationGuard cover the hops after it.
|
|
32
|
+
*
|
|
33
|
+
* @param {import('playwright').BrowserContext} context
|
|
34
|
+
* @param {NetworkPolicy} policy
|
|
35
|
+
*/
|
|
36
|
+
export async function installNetworkPolicy(context, policy) {
|
|
37
|
+
await context.route('**/*', async (route) => {
|
|
38
|
+
const request = route.request();
|
|
39
|
+
let topLevelNavigation = false;
|
|
40
|
+
try {
|
|
41
|
+
topLevelNavigation = request.isNavigationRequest() && request.frame().parentFrame() === null;
|
|
42
|
+
} catch {
|
|
43
|
+
// A request without a frame (a worker's) is not a navigation.
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
if (policy.allows(request.url(), { topLevelNavigation })) await route.fallback();
|
|
48
|
+
else await route.abort('blockedbyclient');
|
|
49
|
+
} catch {
|
|
50
|
+
// The page went away while the request was decided.
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await context.routeWebSocket(/.*/, async (socket) => {
|
|
55
|
+
if (policy.allows(socket.url())) socket.connectToServer();
|
|
56
|
+
else await socket.close({ code: 1008, reason: 'Blocked by the sandbox browser' }).catch(() => {});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function launchChromium(userDataDir, options) {
|
|
61
|
+
const { chromium } = await import('playwright');
|
|
62
|
+
return chromium.launchPersistentContext(userDataDir, options);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Calls `callback` at `time` (epoch ms). setTimeout fires at once for a delay
|
|
66
|
+
// past 2^31 - 1 ms, so a far deadline is reached in steps.
|
|
67
|
+
function stopAt(time, callback) {
|
|
68
|
+
const delay = time - Date.now();
|
|
69
|
+
if (delay <= 0) return callback();
|
|
70
|
+
setTimeout(() => stopAt(time, callback), Math.min(delay, 2 ** 31 - 1));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The Playwright MCP configuration every session's server runs with.
|
|
75
|
+
*
|
|
76
|
+
* @param {{ capabilities: string[] }} config
|
|
77
|
+
* @param {string} outputDir
|
|
78
|
+
*/
|
|
79
|
+
export function mcpConfig(config, outputDir) {
|
|
80
|
+
return {
|
|
81
|
+
browser: { browserName: 'chromium', isolated: false },
|
|
82
|
+
capabilities: config.capabilities,
|
|
83
|
+
// Makes browser_close refuse to close the context the sessions share.
|
|
84
|
+
sharedBrowserContext: true,
|
|
85
|
+
// Pages would otherwise add tools of their own to the agent's list.
|
|
86
|
+
webmcp: false,
|
|
87
|
+
outputDir,
|
|
88
|
+
filePaths: 'absolute',
|
|
89
|
+
codegen: 'none',
|
|
90
|
+
timeouts: { idle: 0 },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Starts streaming `context` to the live view's viewers (LiveServer), with
|
|
96
|
+
* one person at a time able to take control of it.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} config a parsed configuration with `live` set
|
|
99
|
+
* @param {import('playwright').BrowserContext} context
|
|
100
|
+
* @param {{ recorder: Recorder | null, log: (message: string) => void }} options
|
|
101
|
+
* @returns {Promise<{ server: LiveServer, lock: ControlLock, screencast: Screencast }>}
|
|
102
|
+
*/
|
|
103
|
+
async function startLiveView(config, context, { recorder, log }) {
|
|
104
|
+
const lock = new ControlLock({ graceMs: config.live.releaseGraceMs });
|
|
105
|
+
const verifier = new TicketVerifier({ key: ticketKey(config.token), sessionId: config.live.sessionId });
|
|
106
|
+
let server = null;
|
|
107
|
+
const screencast = new Screencast({ context, log, onFrame: (frame) => server?.frame(frame), onPageChange: () => server?.pageChanged() });
|
|
108
|
+
server = new LiveServer({ verifier, lock, screencast, log, onMarker: (data) => recorder?.marker(data) });
|
|
109
|
+
await screencast.attach();
|
|
110
|
+
return { server, lock, screencast };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Starts the browser and its MCP endpoint, and returns once the endpoint
|
|
115
|
+
* listens. Chromium gets a fresh profile directory and is driven over a pipe
|
|
116
|
+
* (Playwright's --remote-debugging-pipe), so no debugging port is opened.
|
|
117
|
+
* Every connection it makes goes through the egress proxy, loopback and
|
|
118
|
+
* WebRTC included. The process's working directory becomes an empty uploads
|
|
119
|
+
* directory, which is where Playwright MCP reads files to upload from.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} config a parsed configuration (see parseConfig)
|
|
122
|
+
* @param {object} [dependencies]
|
|
123
|
+
* @param {Function} [dependencies.launch] chromium.launchPersistentContext
|
|
124
|
+
* @param {Function} [dependencies.createConnection] @playwright/mcp's createConnection
|
|
125
|
+
* @param {NetworkPolicy} [dependencies.policy] the rules for what the browser may load
|
|
126
|
+
* @param {(message: string) => void} [dependencies.log]
|
|
127
|
+
* @returns {Promise<{ port: number, close: (reason?: string, code?: number) => Promise<number>, done: Promise<number> }>}
|
|
128
|
+
* `close` stops everything and removes the directories; `done` settles with
|
|
129
|
+
* the exit status once it has, whatever stopped it (0, or 1 when the
|
|
130
|
+
* browser went away on its own)
|
|
131
|
+
*/
|
|
132
|
+
export async function startSidecar(config, dependencies = {}) {
|
|
133
|
+
const log = dependencies.log ?? ((message) => process.stderr.write(`browser-sidecar: ${message}\n`));
|
|
134
|
+
const launch = dependencies.launch ?? launchChromium;
|
|
135
|
+
const connect = dependencies.createConnection ?? (await import('@playwright/mcp')).createConnection;
|
|
136
|
+
|
|
137
|
+
const ownsWorkdir = config.workdir === null;
|
|
138
|
+
const workdir = config.workdir ?? (await mkdtemp(join(tmpdir(), 'activeagents-browser-')));
|
|
139
|
+
const profileDir = await mkdtemp(join(workdir, 'profile-'));
|
|
140
|
+
const uploadsDir = await mkdtemp(join(workdir, 'uploads-'));
|
|
141
|
+
const outputDir = await mkdtemp(join(workdir, 'output-'));
|
|
142
|
+
const removeDirectories = async () => {
|
|
143
|
+
for (const dir of ownsWorkdir ? [workdir] : [profileDir, uploadsDir, outputDir]) {
|
|
144
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
process.chdir(uploadsDir);
|
|
148
|
+
|
|
149
|
+
const policy = dependencies.policy ?? new NetworkPolicy({ appOrigin: config.appOrigin });
|
|
150
|
+
const proxy = await startEgressProxy({ policy, log });
|
|
151
|
+
let context;
|
|
152
|
+
try {
|
|
153
|
+
context = await launch(profileDir, {
|
|
154
|
+
headless: config.mode === 'headless',
|
|
155
|
+
viewport: VIEWPORT,
|
|
156
|
+
serviceWorkers: 'block',
|
|
157
|
+
// Chromium sends loopback requests around a proxy unless the bypass
|
|
158
|
+
// list names <-loopback>. Playwright adds it unless
|
|
159
|
+
// PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK is set; naming it
|
|
160
|
+
// here keeps it either way.
|
|
161
|
+
proxy: { server: proxy.url, bypass: '<-loopback>' },
|
|
162
|
+
args: WEBRTC_PROXIED_ONLY,
|
|
163
|
+
chromiumSandbox: config.chromiumSandbox,
|
|
164
|
+
handleSIGINT: false,
|
|
165
|
+
handleSIGTERM: false,
|
|
166
|
+
handleSIGHUP: false,
|
|
167
|
+
});
|
|
168
|
+
} catch (error) {
|
|
169
|
+
await proxy.close();
|
|
170
|
+
await removeDirectories();
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await installNetworkPolicy(context, policy);
|
|
175
|
+
if (config.storageState) await context.setStorageState(config.storageState);
|
|
176
|
+
const guard = new NavigationGuard({ policy, log });
|
|
177
|
+
guard.attach(context);
|
|
178
|
+
const recorder = config.recording ? new Recorder({ recording: config.recording, log }) : null;
|
|
179
|
+
await recorder?.attach(context);
|
|
180
|
+
const live = config.live ? await startLiveView(config, context, { recorder, log }) : null;
|
|
181
|
+
|
|
182
|
+
const gateway = new McpGateway({
|
|
183
|
+
connect: () => connect(mcpConfig(config, outputDir), async () => context),
|
|
184
|
+
policy,
|
|
185
|
+
guard,
|
|
186
|
+
snapshotDir: await realpath(outputDir),
|
|
187
|
+
lock: live?.lock,
|
|
188
|
+
agentWaitMs: config.live?.agentWaitMs,
|
|
189
|
+
afterCall: (name, args) => {
|
|
190
|
+
// Playwright MCP numbers tabs from 0 in the order they opened, as context.pages() lists them.
|
|
191
|
+
if (live && name === 'browser_tabs' && args?.action === 'select' && Number.isInteger(args.index)) void live.screencast.followIndex(args.index);
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
let hosts = new Set();
|
|
195
|
+
const liveOrigins = new Set(config.live?.origins ?? []);
|
|
196
|
+
const server = createHttpServer({
|
|
197
|
+
rules: () => ({ token: config.token, hosts, liveOrigins }),
|
|
198
|
+
gateway,
|
|
199
|
+
version: VERSION,
|
|
200
|
+
live: live?.server,
|
|
201
|
+
storageState: () => exportStorageState(context, config.appOrigin),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
let closing = null;
|
|
205
|
+
let finished;
|
|
206
|
+
const done = new Promise((resolve) => {
|
|
207
|
+
finished = resolve;
|
|
208
|
+
});
|
|
209
|
+
// The first reason to stop decides the exit status: closing the context
|
|
210
|
+
// here fires its close event too.
|
|
211
|
+
const close = (reason, exitCode = 0) => {
|
|
212
|
+
closing ??= (async () => {
|
|
213
|
+
if (reason) log(`stopping: ${reason}`);
|
|
214
|
+
server.close();
|
|
215
|
+
server.closeAllConnections();
|
|
216
|
+
live?.server.close();
|
|
217
|
+
await live?.screencast.close();
|
|
218
|
+
await gateway.closeAll();
|
|
219
|
+
// The pages hand over the rrweb events they hold, then the context
|
|
220
|
+
// closes before the recorder, so the recording gets the closing markers.
|
|
221
|
+
if (recorder) await new Promise((resolve) => setTimeout(resolve, PAGE_FLUSH_MS * 2));
|
|
222
|
+
await context.close().catch(() => {});
|
|
223
|
+
await proxy.close();
|
|
224
|
+
await recorder?.close(RECORDING_DRAIN_MS);
|
|
225
|
+
await removeDirectories();
|
|
226
|
+
finished(exitCode);
|
|
227
|
+
return exitCode;
|
|
228
|
+
})();
|
|
229
|
+
return closing;
|
|
230
|
+
};
|
|
231
|
+
context.on('close', () => void close('the browser closed', 1));
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
await new Promise((resolve, reject) => {
|
|
235
|
+
server.once('error', reject);
|
|
236
|
+
server.listen(config.port, config.host, resolve);
|
|
237
|
+
});
|
|
238
|
+
} catch (error) {
|
|
239
|
+
await close();
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
const { port } = server.address();
|
|
243
|
+
hosts = acceptedHosts(config.host, port, config.allowedHosts);
|
|
244
|
+
|
|
245
|
+
if (config.stopAt !== null) stopAt(config.stopAt, () => void close('the sandbox expired'));
|
|
246
|
+
|
|
247
|
+
// The first page opens on the app, so the recording starts there.
|
|
248
|
+
const [page] = context.pages();
|
|
249
|
+
page?.goto(config.appOrigin).catch((error) => log(`could not open ${config.appOrigin}: ${error.message}`));
|
|
250
|
+
|
|
251
|
+
return { port, close, done };
|
|
252
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// A Playwright storage state (cookies and per-origin localStorage) limited to
|
|
2
|
+
// the sandbox app, the only site its browser opens. The engine keeps one
|
|
3
|
+
// as a project secret, so a later browser of the same app starts signed in.
|
|
4
|
+
|
|
5
|
+
const SAME_SITE = new Set(['Strict', 'Lax', 'None']);
|
|
6
|
+
|
|
7
|
+
export class StorageStateError extends Error {}
|
|
8
|
+
|
|
9
|
+
function fail(message) {
|
|
10
|
+
throw new StorageStateError(message);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Whether a cookie for `domain` is sent to `hostname`: the same host, or a
|
|
14
|
+
// parent domain the cookie names with or without its leading dot.
|
|
15
|
+
function cookieDomainMatches(domain, hostname) {
|
|
16
|
+
const bare = String(domain).replace(/^\./, '').toLowerCase();
|
|
17
|
+
return bare !== '' && (hostname === bare || hostname.endsWith(`.${bare}`));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function cookie(value, index) {
|
|
21
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail(`cookie ${index + 1} must be an object`);
|
|
22
|
+
for (const key of ['name', 'value', 'domain', 'path']) {
|
|
23
|
+
if (typeof value[key] !== 'string') fail(`cookie ${index + 1} needs a string ${key}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const result = { name: value.name, value: value.value, domain: value.domain, path: value.path || '/' };
|
|
27
|
+
result.expires = Number.isFinite(value.expires) ? value.expires : -1;
|
|
28
|
+
result.httpOnly = value.httpOnly === true;
|
|
29
|
+
result.secure = value.secure === true;
|
|
30
|
+
result.sameSite = SAME_SITE.has(value.sameSite) ? value.sameSite : 'Lax';
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function origin(value, index) {
|
|
35
|
+
if (!value || typeof value !== 'object' || typeof value.origin !== 'string') fail(`origin ${index + 1} needs an origin`);
|
|
36
|
+
const localStorage = value.localStorage ?? [];
|
|
37
|
+
if (!Array.isArray(localStorage) || localStorage.some((item) => typeof item?.name !== 'string' || typeof item?.value !== 'string')) {
|
|
38
|
+
fail(`origin ${index + 1}'s localStorage must be a list of { name, value } strings`);
|
|
39
|
+
}
|
|
40
|
+
return { origin: value.origin, localStorage: localStorage.map(({ name, value: item }) => ({ name, value: item })) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validates a storage state and returns the part of it that belongs to
|
|
45
|
+
* `appOrigin`: the cookies its host is sent and the localStorage of that
|
|
46
|
+
* origin. Anything else is dropped rather than refused, since a state saved
|
|
47
|
+
* from a browser that followed a link elsewhere still signs the app in.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} state { cookies: [...], origins: [...] }
|
|
50
|
+
* @param {string} appOrigin e.g. "http://127.0.0.1:3000"
|
|
51
|
+
* @returns {{ cookies: object[], origins: object[] }}
|
|
52
|
+
* @throws {StorageStateError} naming the first malformed entry
|
|
53
|
+
*/
|
|
54
|
+
export function appStorageState(state, appOrigin) {
|
|
55
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) fail('storage_state must be an object');
|
|
56
|
+
const cookies = state.cookies ?? [];
|
|
57
|
+
const origins = state.origins ?? [];
|
|
58
|
+
if (!Array.isArray(cookies) || !Array.isArray(origins)) fail('storage_state needs cookies and origins lists');
|
|
59
|
+
|
|
60
|
+
const { hostname } = new URL(appOrigin);
|
|
61
|
+
return {
|
|
62
|
+
cookies: cookies.map(cookie).filter((entry) => cookieDomainMatches(entry.domain, hostname.toLowerCase())),
|
|
63
|
+
origins: origins.map(origin).filter((entry) => entry.origin === appOrigin),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The browser context's storage state limited to `appOrigin` (see
|
|
69
|
+
* appStorageState).
|
|
70
|
+
*
|
|
71
|
+
* @param {import('playwright').BrowserContext} context
|
|
72
|
+
* @param {string} appOrigin
|
|
73
|
+
*/
|
|
74
|
+
export async function exportStorageState(context, appOrigin) {
|
|
75
|
+
return appStorageState(await context.storageState(), appOrigin);
|
|
76
|
+
}
|
package/lib/version.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
// The engine refuses a sidecar whose version is not its own, so the version
|
|
4
|
+
// is read from the package the code shipped in rather than kept twice.
|
|
5
|
+
export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@activeagents/browser-sidecar",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.9.0",
|
|
4
|
+
"description": "One sandbox's browser for the Active Agent dashboard: Chromium driven over Playwright MCP, recorded with rrweb.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"homepage": "https://docs.activeagents.ai/framework/browser-sessions",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/activeagents/activeagent.git",
|
|
11
|
+
"directory": "browser-sidecar"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/activeagents/activeagent/issues",
|
|
14
|
+
"bin": {
|
|
15
|
+
"activeagents-browser-sidecar": "bin/browser-sidecar.mjs"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin",
|
|
19
|
+
"lib",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test --test-timeout=120000 test/*.test.mjs"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@playwright/mcp": "0.0.83",
|
|
31
|
+
"@rrweb/record": "2.1.7",
|
|
32
|
+
"playwright": "1.64.0-alpha-1790635538000",
|
|
33
|
+
"ws": "8.22.0"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
}
|
|
38
|
+
}
|