@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
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
3
|
+
import { sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { TAKEOVER_META } from './control-lock.mjs';
|
|
6
|
+
|
|
7
|
+
// Never offered and never called: browser_run_code_unsafe runs JavaScript in
|
|
8
|
+
// this process, where the tokens are.
|
|
9
|
+
export const DENIED_TOOLS = new Set(['browser_run_code_unsafe']);
|
|
10
|
+
|
|
11
|
+
// The tool argument holding the URL a tool opens, for the tools that open one.
|
|
12
|
+
const NAVIGATION_ARGUMENTS = {
|
|
13
|
+
browser_navigate: () => 'url',
|
|
14
|
+
browser_tabs: (args) => (args.action === 'new' && args.url !== undefined ? 'url' : null),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const JSONRPC = '2.0';
|
|
18
|
+
// A page snapshot an action wrote to a file, and how much of one is inlined.
|
|
19
|
+
const SNAPSHOT_LINK = /(?:- )?\[Snapshot\]\(([^)\n]+)\)/g;
|
|
20
|
+
const SNAPSHOT_LIMIT = 256 * 1024;
|
|
21
|
+
|
|
22
|
+
function rpcError(id, code, message) {
|
|
23
|
+
return { jsonrpc: JSONRPC, id: id ?? null, error: { code, message } };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toolError(id, text) {
|
|
27
|
+
return { jsonrpc: JSONRPC, id, result: { content: [{ type: 'text', text: `### Error\n${text}` }], isError: true } };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The answer to an agent's call refused because a person held control for
|
|
31
|
+
// all of `waitedMs`. Its _meta names who, for a client that pauses the run
|
|
32
|
+
// until they hand back rather than reading the error.
|
|
33
|
+
function takeoverError(id, holder, waitedMs) {
|
|
34
|
+
const who = holder.user.name ? `${holder.user.name} is` : 'A person is';
|
|
35
|
+
const text = `${who} driving this browser by hand and did not hand it back within ${Math.round(waitedMs / 1000)} seconds. ` +
|
|
36
|
+
'Nothing was done. Try again once they hand it back.';
|
|
37
|
+
const result = toolError(id, text);
|
|
38
|
+
result.result._meta = { [TAKEOVER_META]: { held_by: holder.user.name, since: new Date(holder.since).toISOString(), waited_ms: waitedMs } };
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isRequest(message) {
|
|
43
|
+
return typeof message.method === 'string' && message.id !== undefined && message.id !== null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The server side of one MCP session, held in memory: a request goes in
|
|
47
|
+
// through `request` and its response comes back through the server's call to
|
|
48
|
+
// `send`.
|
|
49
|
+
// Notifications and requests the server sends on its own are dropped, since
|
|
50
|
+
// a JSON response has nowhere to carry them.
|
|
51
|
+
class MemoryTransport {
|
|
52
|
+
constructor(timeoutMs) {
|
|
53
|
+
this.timeoutMs = timeoutMs;
|
|
54
|
+
this.pending = new Map();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async start() {}
|
|
58
|
+
|
|
59
|
+
async send(message) {
|
|
60
|
+
const isResponse = message && message.id !== undefined && ('result' in message || 'error' in message);
|
|
61
|
+
if (!isResponse) return;
|
|
62
|
+
|
|
63
|
+
const waiter = this.pending.get(message.id);
|
|
64
|
+
if (!waiter) return;
|
|
65
|
+
this.pending.delete(message.id);
|
|
66
|
+
clearTimeout(waiter.timer);
|
|
67
|
+
waiter.resolve(message);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async close() {
|
|
71
|
+
if (this.closed) return;
|
|
72
|
+
this.closed = true;
|
|
73
|
+
for (const [id, waiter] of this.pending) {
|
|
74
|
+
clearTimeout(waiter.timer);
|
|
75
|
+
waiter.resolve(rpcError(id, -32000, 'The session closed'));
|
|
76
|
+
}
|
|
77
|
+
this.pending.clear();
|
|
78
|
+
this.onclose?.();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
request(message) {
|
|
82
|
+
if (this.closed) return Promise.resolve(rpcError(message.id, -32000, 'The session closed'));
|
|
83
|
+
if (this.pending.has(message.id)) return Promise.resolve(rpcError(message.id, -32600, 'A request with this id is in flight'));
|
|
84
|
+
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
const timer = setTimeout(() => {
|
|
87
|
+
this.pending.delete(message.id);
|
|
88
|
+
resolve(rpcError(message.id, -32001, 'The request timed out'));
|
|
89
|
+
}, this.timeoutMs);
|
|
90
|
+
this.pending.set(message.id, { resolve, timer });
|
|
91
|
+
this.onmessage?.(message);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
notify(message) {
|
|
96
|
+
this.onmessage?.(message);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Used to serve MCP over plain JSON responses: each POST carries one JSON-RPC
|
|
102
|
+
* message and gets the response back as the body.
|
|
103
|
+
*
|
|
104
|
+
* Every session gets its own server from `connect`, all of them driving the
|
|
105
|
+
* same browser. The gateway reads each message on its way in and out, so a
|
|
106
|
+
* denied tool is neither listed nor called, a navigation is checked against
|
|
107
|
+
* the network policy before it reaches the browser, and the client declares
|
|
108
|
+
* no capability the server would call back for (such as roots, which would
|
|
109
|
+
* move where files are read from).
|
|
110
|
+
*
|
|
111
|
+
* Playwright MCP writes the page snapshot an action produces to a file and
|
|
112
|
+
* answers with a link to it, which a client on another machine cannot open.
|
|
113
|
+
* With `snapshotDir` set, a link to a file in that directory is replaced by
|
|
114
|
+
* the snapshot itself.
|
|
115
|
+
*
|
|
116
|
+
* With `guard` set, a tool call during which a page was redirected off the
|
|
117
|
+
* app gets an error instead of its result, which could describe that page.
|
|
118
|
+
*
|
|
119
|
+
* With `lock` set, a tool call that would change the page waits while a
|
|
120
|
+
* person holds control (ControlLock#admit), for up to `agentWaitMs`, and is
|
|
121
|
+
* answered with an error naming them when they have not handed it back by
|
|
122
|
+
* then.
|
|
123
|
+
*/
|
|
124
|
+
export class McpGateway {
|
|
125
|
+
/**
|
|
126
|
+
* @param {object} options
|
|
127
|
+
* @param {() => Promise<{ connect(transport: object): Promise<void>, close(): Promise<void> }>} options.connect
|
|
128
|
+
* returns a new MCP server for a session
|
|
129
|
+
* @param {{ navigationTarget(url: string): { url: string, refusal: string | null } }} options.policy
|
|
130
|
+
* @param {Set<string>} [options.deniedTools]
|
|
131
|
+
* @param {number} [options.maxSessions] sessions kept at once; the least recently used idle one is closed for a new one
|
|
132
|
+
* @param {number} [options.idleMs] how long an unused session is kept
|
|
133
|
+
* @param {number} [options.requestTimeoutMs] how long a request may take
|
|
134
|
+
* @param {string} [options.snapshotDir] the real path of the directory snapshots are written to
|
|
135
|
+
* @param {{ escapes: number, refusalSince(since: number): Promise<string | null> }} [options.guard] a NavigationGuard
|
|
136
|
+
* @param {import('./control-lock.mjs').ControlLock} [options.lock]
|
|
137
|
+
* @param {number} [options.agentWaitMs]
|
|
138
|
+
* @param {(name: string, args: object) => void} [options.afterCall] told of each tool call that succeeded
|
|
139
|
+
*/
|
|
140
|
+
constructor({
|
|
141
|
+
connect, policy, deniedTools = DENIED_TOOLS, maxSessions = 8, idleMs = 30 * 60_000, requestTimeoutMs = 120_000, snapshotDir = null,
|
|
142
|
+
guard = null, lock = null, agentWaitMs = 0, afterCall = () => {},
|
|
143
|
+
}) {
|
|
144
|
+
this.connect = connect;
|
|
145
|
+
this.policy = policy;
|
|
146
|
+
this.guard = guard;
|
|
147
|
+
this.lock = lock;
|
|
148
|
+
this.agentWaitMs = agentWaitMs;
|
|
149
|
+
this.afterCall = afterCall;
|
|
150
|
+
this.snapshotDir = snapshotDir;
|
|
151
|
+
this.deniedTools = deniedTools;
|
|
152
|
+
this.maxSessions = maxSessions;
|
|
153
|
+
this.idleMs = idleMs;
|
|
154
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
155
|
+
this.sessions = new Map();
|
|
156
|
+
this.uses = 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Answers one POSTed message.
|
|
161
|
+
*
|
|
162
|
+
* @param {unknown} message the parsed body
|
|
163
|
+
* @param {string | undefined} sessionId the Mcp-Session-Id header
|
|
164
|
+
* @returns {Promise<{ status: number, headers?: object, body?: object }>}
|
|
165
|
+
*/
|
|
166
|
+
async handle(message, sessionId) {
|
|
167
|
+
if (!message || typeof message !== 'object' || Array.isArray(message) || message.jsonrpc !== JSONRPC) {
|
|
168
|
+
return { status: 400, body: rpcError(null, -32600, 'The body must be one JSON-RPC 2.0 message') };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (message.method === 'initialize') return this.initialize(message);
|
|
172
|
+
|
|
173
|
+
if (!sessionId) return { status: 400, body: rpcError(message.id, -32600, 'Mcp-Session-Id header is required') };
|
|
174
|
+
const session = this.sessions.get(sessionId);
|
|
175
|
+
if (!session) return { status: 404, body: rpcError(message.id, -32001, 'Session not found') };
|
|
176
|
+
|
|
177
|
+
if (!isRequest(message)) {
|
|
178
|
+
if (typeof message.method === 'string') session.transport.notify(message);
|
|
179
|
+
return { status: 202 };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const inbound = this.inbound(message);
|
|
183
|
+
if (inbound.answer) return { status: 200, body: inbound.answer };
|
|
184
|
+
|
|
185
|
+
this.touch(session);
|
|
186
|
+
session.busy += 1;
|
|
187
|
+
try {
|
|
188
|
+
const call = message.method === 'tools/call' ? inbound.message.params : null;
|
|
189
|
+
if (call && this.lock) {
|
|
190
|
+
const waitStarted = Date.now();
|
|
191
|
+
const refusal = await this.lock.admit(call.name, call.arguments, this.agentWaitMs);
|
|
192
|
+
if (refusal?.stopping) return { status: 200, body: toolError(message.id, 'The browser is stopping. Nothing was done.') };
|
|
193
|
+
if (refusal) return { status: 200, body: takeoverError(message.id, refusal.holder, Date.now() - waitStarted) };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const escapes = this.guard?.escapes ?? 0;
|
|
197
|
+
const response = await session.transport.request(inbound.message);
|
|
198
|
+
const refusal = call ? await this.guard?.refusalSince(escapes) : null;
|
|
199
|
+
if (refusal) return { status: 200, body: toolError(message.id, refusal) };
|
|
200
|
+
if (call && response.result && !response.result.isError) this.afterCall(call.name, call.arguments);
|
|
201
|
+
|
|
202
|
+
return { status: 200, body: await this.outbound(message, response) };
|
|
203
|
+
} finally {
|
|
204
|
+
session.busy -= 1;
|
|
205
|
+
this.touch(session);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async initialize(message) {
|
|
210
|
+
if (!isRequest(message)) return { status: 400, body: rpcError(null, -32600, 'initialize must be a request') };
|
|
211
|
+
this.closeIdleSessions();
|
|
212
|
+
if (this.sessions.size >= this.maxSessions && !this.closeLeastRecentlyUsed()) {
|
|
213
|
+
return { status: 503, body: rpcError(message.id, -32000, 'Too many sessions are busy; try again shortly') };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const server = await this.connect();
|
|
217
|
+
const transport = new MemoryTransport(this.requestTimeoutMs);
|
|
218
|
+
await server.connect(transport);
|
|
219
|
+
|
|
220
|
+
const id = randomUUID();
|
|
221
|
+
const session = { server, transport, busy: 1 };
|
|
222
|
+
this.touch(session);
|
|
223
|
+
this.sessions.set(id, session);
|
|
224
|
+
try {
|
|
225
|
+
const params = { ...(message.params ?? {}), capabilities: {} };
|
|
226
|
+
const response = await transport.request({ ...message, params });
|
|
227
|
+
if (response.error) {
|
|
228
|
+
await this.closeSession(id);
|
|
229
|
+
return { status: 200, body: response };
|
|
230
|
+
}
|
|
231
|
+
return { status: 200, headers: { 'mcp-session-id': id }, body: response };
|
|
232
|
+
} finally {
|
|
233
|
+
session.busy -= 1;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// The message as the server should see it, or the answer to give without
|
|
238
|
+
// asking the server at all.
|
|
239
|
+
inbound(message) {
|
|
240
|
+
if (message.method !== 'tools/call') return { message };
|
|
241
|
+
|
|
242
|
+
const name = message.params?.name;
|
|
243
|
+
if (this.deniedTools.has(name)) return { answer: toolError(message.id, `The ${name} tool is not available in this browser`) };
|
|
244
|
+
|
|
245
|
+
const args = { ...(message.params?.arguments ?? {}) };
|
|
246
|
+
// _meta can move the directory file names resolve against.
|
|
247
|
+
delete args._meta;
|
|
248
|
+
|
|
249
|
+
const argument = NAVIGATION_ARGUMENTS[name]?.(args);
|
|
250
|
+
if (argument) {
|
|
251
|
+
const target = this.policy.navigationTarget(args[argument]);
|
|
252
|
+
if (target.refusal) return { answer: toolError(message.id, target.refusal) };
|
|
253
|
+
args[argument] = target.url;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { message: { ...message, params: { ...message.params, arguments: args } } };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async outbound(request, response) {
|
|
260
|
+
if (request.method === 'tools/list' && Array.isArray(response.result?.tools)) {
|
|
261
|
+
const tools = response.result.tools.filter((tool) => !this.deniedTools.has(tool.name));
|
|
262
|
+
return { ...response, result: { ...response.result, tools } };
|
|
263
|
+
}
|
|
264
|
+
if (request.method === 'tools/call' && this.snapshotDir && Array.isArray(response.result?.content)) {
|
|
265
|
+
const content = await Promise.all(response.result.content.map((block) => this.inlineSnapshots(block)));
|
|
266
|
+
return { ...response, result: { ...response.result, content } };
|
|
267
|
+
}
|
|
268
|
+
return response;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async inlineSnapshots(block) {
|
|
272
|
+
if (block?.type !== 'text' || typeof block.text !== 'string' || !block.text.includes('[Snapshot](')) return block;
|
|
273
|
+
|
|
274
|
+
const snapshots = new Map();
|
|
275
|
+
for (const [, path] of block.text.matchAll(SNAPSHOT_LINK)) snapshots.set(path, await this.readSnapshot(path));
|
|
276
|
+
|
|
277
|
+
const text = block.text.replace(SNAPSHOT_LINK, (link, path) => {
|
|
278
|
+
const snapshot = snapshots.get(path);
|
|
279
|
+
return snapshot === null ? link : `\`\`\`yaml\n${snapshot}\n\`\`\``;
|
|
280
|
+
});
|
|
281
|
+
return { ...block, text };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// The snapshot at `path`, cut to SNAPSHOT_LIMIT, or null when the path is
|
|
285
|
+
// not a file in snapshotDir.
|
|
286
|
+
async readSnapshot(path) {
|
|
287
|
+
try {
|
|
288
|
+
const resolved = await realpath(path);
|
|
289
|
+
if (!resolved.startsWith(`${this.snapshotDir}${sep}`)) return null;
|
|
290
|
+
|
|
291
|
+
const text = await readFile(resolved, 'utf8');
|
|
292
|
+
return text.length > SNAPSHOT_LIMIT ? `${text.slice(0, SNAPSHOT_LIMIT)}\n# … (truncated, ${text.length} characters in all)` : text;
|
|
293
|
+
} catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// lastUsed is when, for the idle timeout; use orders sessions by recency,
|
|
299
|
+
// which two uses in the same millisecond still tell apart.
|
|
300
|
+
touch(session) {
|
|
301
|
+
session.lastUsed = Date.now();
|
|
302
|
+
session.use = ++this.uses;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
closeIdleSessions() {
|
|
306
|
+
const cutoff = Date.now() - this.idleMs;
|
|
307
|
+
for (const [id, session] of this.sessions) {
|
|
308
|
+
if (session.busy === 0 && session.lastUsed < cutoff) void this.closeSession(id);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
closeLeastRecentlyUsed() {
|
|
313
|
+
const idle = [...this.sessions].filter(([, session]) => session.busy === 0).sort(([, a], [, b]) => a.use - b.use);
|
|
314
|
+
if (idle.length === 0) return false;
|
|
315
|
+
|
|
316
|
+
void this.closeSession(idle[0][0]);
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Closes one session's server. The browser stays open.
|
|
322
|
+
*
|
|
323
|
+
* @param {string} id
|
|
324
|
+
* @returns {Promise<boolean>} whether there was such a session
|
|
325
|
+
*/
|
|
326
|
+
async closeSession(id) {
|
|
327
|
+
const session = this.sessions.get(id);
|
|
328
|
+
if (!session) return false;
|
|
329
|
+
|
|
330
|
+
this.sessions.delete(id);
|
|
331
|
+
await session.server.close().catch(() => {});
|
|
332
|
+
await session.transport.close();
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async closeAll() {
|
|
337
|
+
await Promise.all([...this.sessions.keys()].map((id) => this.closeSession(id)));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// How many of the latest escapes are kept to name in a refusal.
|
|
2
|
+
const KEPT_ESCAPES = 10;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Used to keep every tab on the sandbox app. A navigation that starts
|
|
6
|
+
* elsewhere never leaves (see NetworkPolicy#allows), but Chromium follows an
|
|
7
|
+
* HTTP redirect from an app page without asking the policy again, and the
|
|
8
|
+
* egress proxy only refuses private addresses. A tab that lands on another
|
|
9
|
+
* origin that way has escaped. It is closed at once, which also discards
|
|
10
|
+
* what Playwright MCP collected from it (its requests and console messages),
|
|
11
|
+
* and the escape is counted so the MCP gateway can withhold the result of
|
|
12
|
+
* any tool call it happened during. When it was the only tab, a blank one is
|
|
13
|
+
* opened first, so the browser keeps a page.
|
|
14
|
+
*/
|
|
15
|
+
export class NavigationGuard {
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} options
|
|
18
|
+
* @param {{ onApp(url: string): boolean }} options.policy
|
|
19
|
+
* @param {(message: string) => void} [options.log]
|
|
20
|
+
*/
|
|
21
|
+
constructor({ policy, log = () => {} }) {
|
|
22
|
+
this.policy = policy;
|
|
23
|
+
this.log = log;
|
|
24
|
+
this.escapes = 0;
|
|
25
|
+
this.recent = [];
|
|
26
|
+
this.pages = new Set();
|
|
27
|
+
this.closing = new Set();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Watches every page `context` has and opens.
|
|
32
|
+
*
|
|
33
|
+
* @param {import('playwright').BrowserContext} context
|
|
34
|
+
*/
|
|
35
|
+
attach(context) {
|
|
36
|
+
this.context = context;
|
|
37
|
+
for (const page of context.pages()) this.watch(page);
|
|
38
|
+
context.on('page', (page) => this.watch(page));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
watch(page) {
|
|
42
|
+
this.pages.add(page);
|
|
43
|
+
page.on('close', () => this.pages.delete(page));
|
|
44
|
+
page.on('framenavigated', (frame) => {
|
|
45
|
+
if (frame !== page.mainFrame() || this.policy.onApp(frame.url())) return;
|
|
46
|
+
|
|
47
|
+
const { origin } = new URL(frame.url());
|
|
48
|
+
this.escapes += 1;
|
|
49
|
+
this.recent = [...this.recent, { count: this.escapes, origin }].slice(-KEPT_ESCAPES);
|
|
50
|
+
this.log(`closed a page that was redirected to ${origin}`);
|
|
51
|
+
const closing = this.close(page);
|
|
52
|
+
this.closing.add(closing);
|
|
53
|
+
closing.finally(() => this.closing.delete(closing));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async close(page) {
|
|
58
|
+
try {
|
|
59
|
+
if (this.context.pages().every((other) => other === page)) await this.context.newPage();
|
|
60
|
+
await page.close();
|
|
61
|
+
} catch {
|
|
62
|
+
// The browser is closing.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Why a tool call that began when `escapes` was `since` may not return its
|
|
68
|
+
* result: a page escaped while it ran, or one is still off the app. Null
|
|
69
|
+
* when neither. Resolves once the escaped pages are closed, so the next
|
|
70
|
+
* call finds the browser on a page it may use.
|
|
71
|
+
*
|
|
72
|
+
* @param {number} since
|
|
73
|
+
* @returns {Promise<string | null>}
|
|
74
|
+
*/
|
|
75
|
+
async refusalSince(since) {
|
|
76
|
+
await Promise.all(this.closing);
|
|
77
|
+
const origins = new Set(this.recent.filter((escape) => escape.count > since).map((escape) => escape.origin));
|
|
78
|
+
for (const page of this.pages) {
|
|
79
|
+
if (!this.policy.onApp(page.url())) origins.add(new URL(page.url()).origin);
|
|
80
|
+
}
|
|
81
|
+
if (this.escapes === since && origins.size === 0) return null;
|
|
82
|
+
|
|
83
|
+
const where = origins.size > 0 ? ` to ${[...origins].join(', ')}` : '';
|
|
84
|
+
return `A page was redirected${where}, outside the sandbox app, and was closed. ` +
|
|
85
|
+
'This browser may only show pages of the sandbox app, so the result of this call is withheld.';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { lookup } from 'node:dns/promises';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
|
|
4
|
+
// IPv4 ranges that are not the public internet: this host, private networks,
|
|
5
|
+
// carrier-grade NAT, loopback, link-local (cloud metadata lives there),
|
|
6
|
+
// benchmarking, multicast and reserved.
|
|
7
|
+
const PRIVATE_IPV4 = [
|
|
8
|
+
['0.0.0.0', 8],
|
|
9
|
+
['10.0.0.0', 8],
|
|
10
|
+
['100.64.0.0', 10],
|
|
11
|
+
['127.0.0.0', 8],
|
|
12
|
+
['169.254.0.0', 16],
|
|
13
|
+
['172.16.0.0', 12],
|
|
14
|
+
['192.0.0.0', 24],
|
|
15
|
+
['192.168.0.0', 16],
|
|
16
|
+
['198.18.0.0', 15],
|
|
17
|
+
['224.0.0.0', 4],
|
|
18
|
+
['240.0.0.0', 4],
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function ipv4ToInt(address) {
|
|
22
|
+
return address.split('.').reduce((total, part) => total * 256 + Number(part), 0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function inIpv4Range(address, [base, bits]) {
|
|
26
|
+
const size = 2 ** (32 - bits);
|
|
27
|
+
return Math.floor(ipv4ToInt(address) / size) === Math.floor(ipv4ToInt(base) / size);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The eight 16-bit groups of an IPv6 address, an embedded dotted IPv4 tail
|
|
31
|
+
// included, or null when it is not one.
|
|
32
|
+
function ipv6Groups(address) {
|
|
33
|
+
let text = address.toLowerCase().replace(/%.*$/, '');
|
|
34
|
+
const dotted = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
|
|
35
|
+
if (dotted) {
|
|
36
|
+
const value = ipv4ToInt(dotted[1]);
|
|
37
|
+
text = `${text.slice(0, -dotted[1].length)}${(value >>> 16).toString(16)}:${(value & 0xffff).toString(16)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const halves = text.split('::');
|
|
41
|
+
if (halves.length > 2) return null;
|
|
42
|
+
const head = halves[0] === '' ? [] : halves[0].split(':');
|
|
43
|
+
const tail = halves.length === 2 && halves[1] !== '' ? halves[1].split(':') : [];
|
|
44
|
+
const missing = 8 - head.length - tail.length;
|
|
45
|
+
if (halves.length === 1 ? missing !== 0 : missing < 1) return null;
|
|
46
|
+
|
|
47
|
+
const groups = [...head, ...Array(halves.length === 2 ? missing : 0).fill('0'), ...tail].map((group) => parseInt(group, 16));
|
|
48
|
+
return groups.every((group) => Number.isInteger(group) && group >= 0 && group <= 0xffff) ? groups : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function groupsToIpv4(high, low) {
|
|
52
|
+
return [high >> 8, high & 255, low >> 8, low & 255].join('.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isPrivateIpv6(address) {
|
|
56
|
+
const groups = ipv6Groups(address);
|
|
57
|
+
if (!groups) return false;
|
|
58
|
+
|
|
59
|
+
const [first] = groups;
|
|
60
|
+
const leadingZeros = groups.findIndex((group) => group !== 0);
|
|
61
|
+
if (leadingZeros === -1) return true; // ::
|
|
62
|
+
if (leadingZeros === 7 && groups[7] === 1) return true; // ::1
|
|
63
|
+
// ::ffff:a.b.c.d (mapped) and ::a.b.c.d (compatible) carry an IPv4 address,
|
|
64
|
+
// and so does 64:ff9b::a.b.c.d (NAT64).
|
|
65
|
+
const mapped = leadingZeros >= 5 && (groups[5] === 0xffff || (leadingZeros >= 6 && groups[5] === 0));
|
|
66
|
+
const nat64 = first === 0x64 && groups[1] === 0xff9b && groups.slice(2, 6).every((group) => group === 0);
|
|
67
|
+
if (mapped || nat64) return isPrivateAddress(groupsToIpv4(groups[6], groups[7]));
|
|
68
|
+
|
|
69
|
+
// fc00::/7 unique local, fe80::/10 link-local, fec0::/10 site-local, ff00::/8 multicast.
|
|
70
|
+
return (first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80 || (first & 0xffc0) === 0xfec0 || (first & 0xff00) === 0xff00;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether `address`, an IP literal, is loopback, link-local, private or
|
|
75
|
+
* otherwise not on the public internet. A name that is not an IP is false.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} address
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
export function isPrivateAddress(address) {
|
|
81
|
+
const bare = address.replace(/^\[|\]$/g, '');
|
|
82
|
+
const family = isIP(bare);
|
|
83
|
+
if (family === 4) return PRIVATE_IPV4.some((range) => inIpv4Range(bare, range));
|
|
84
|
+
return family === 6 && isPrivateIpv6(bare);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether `hostname` names this machine or a local network by name alone.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} hostname
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
export function isLocalName(hostname) {
|
|
94
|
+
const name = hostname.toLowerCase().replace(/\.$/, '');
|
|
95
|
+
return name === 'localhost' || name.endsWith('.localhost') || name.endsWith('.local') || name.endsWith('.internal');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function resolveAll(hostname) {
|
|
99
|
+
const records = await lookup(hostname, { all: true, verbatim: true });
|
|
100
|
+
return records.map((record) => record.address);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function bare(hostname) {
|
|
104
|
+
return hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function defaultPort(protocol) {
|
|
108
|
+
return protocol === 'https:' || protocol === 'wss:' ? 443 : 80;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Used to decide what the browser may load, at two levels.
|
|
113
|
+
*
|
|
114
|
+
* `allows` judges a request by its URL, before it is sent: a top-level
|
|
115
|
+
* navigation may only open the sandbox app's origin, and no URL may name a
|
|
116
|
+
* loopback, link-local or private address, or a local host name, other than
|
|
117
|
+
* the app's. Chromium follows an HTTP redirect without asking again, so this
|
|
118
|
+
* only sees the first URL of a redirect chain.
|
|
119
|
+
*
|
|
120
|
+
* `destination` judges each connection the browser opens, through the
|
|
121
|
+
* sidecar's egress proxy, whatever URL or redirect led to it. The app's own
|
|
122
|
+
* host and port are always reachable. Anything else must resolve only to
|
|
123
|
+
* public addresses, and the proxy connects to the address checked here, so a
|
|
124
|
+
* name whose answer changes between two lookups cannot move the connection
|
|
125
|
+
* onto a private address.
|
|
126
|
+
*/
|
|
127
|
+
export class NetworkPolicy {
|
|
128
|
+
/**
|
|
129
|
+
* @param {object} options
|
|
130
|
+
* @param {string} options.appOrigin the sandbox app's origin
|
|
131
|
+
* @param {(hostname: string) => Promise<string[]>} [options.resolve] how host names are resolved
|
|
132
|
+
* @param {(address: string) => boolean} [options.isPrivate] which IP addresses are refused
|
|
133
|
+
* @param {number} [options.cacheMs] how long a resolution is reused
|
|
134
|
+
*/
|
|
135
|
+
constructor({ appOrigin, resolve = resolveAll, isPrivate = isPrivateAddress, cacheMs = 60_000 }) {
|
|
136
|
+
this.appOrigin = appOrigin;
|
|
137
|
+
const app = new URL(appOrigin);
|
|
138
|
+
this.appHost = bare(app.hostname);
|
|
139
|
+
this.appPort = Number(app.port || defaultPort(app.protocol));
|
|
140
|
+
this.resolve = resolve;
|
|
141
|
+
this.isPrivate = isPrivate;
|
|
142
|
+
this.cacheMs = cacheMs;
|
|
143
|
+
this.resolved = new Map();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Returns `url` as the absolute URL a navigation would open, and why it
|
|
148
|
+
* may not, if it may not. A path ("/login") is resolved against the app.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} url
|
|
151
|
+
* @returns {{ url: string, refusal: string | null }}
|
|
152
|
+
*/
|
|
153
|
+
navigationTarget(url) {
|
|
154
|
+
const text = String(url ?? '').trim();
|
|
155
|
+
if (text === 'about:blank') return { url: text, refusal: null };
|
|
156
|
+
|
|
157
|
+
let target;
|
|
158
|
+
try {
|
|
159
|
+
target = text.startsWith('/') && !text.startsWith('//') ? new URL(text, this.appOrigin) : new URL(text);
|
|
160
|
+
} catch {
|
|
161
|
+
return { url: text, refusal: this.refusalMessage(text) };
|
|
162
|
+
}
|
|
163
|
+
if (target.origin !== this.appOrigin) return { url: text, refusal: this.refusalMessage(text) };
|
|
164
|
+
|
|
165
|
+
return { url: target.toString(), refusal: null };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
refusalMessage(url) {
|
|
169
|
+
return `Navigation to ${url} was refused: this browser may only open pages of the sandbox app at ${this.appOrigin}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Whether a page on `url` is one of the sandbox app's. A page that is not
|
|
174
|
+
* on the web (about:blank, an error page) is not elsewhere either.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} url
|
|
177
|
+
* @returns {boolean}
|
|
178
|
+
*/
|
|
179
|
+
onApp(url) {
|
|
180
|
+
let target;
|
|
181
|
+
try {
|
|
182
|
+
target = new URL(url);
|
|
183
|
+
} catch {
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
if (target.protocol !== 'http:' && target.protocol !== 'https:') return true;
|
|
187
|
+
return target.origin === this.appOrigin;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Whether the browser may send a request to `url`, judged by the URL alone.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} url
|
|
194
|
+
* @param {{ topLevelNavigation?: boolean }} [options]
|
|
195
|
+
* @returns {boolean}
|
|
196
|
+
*/
|
|
197
|
+
allows(url, { topLevelNavigation = false } = {}) {
|
|
198
|
+
let target;
|
|
199
|
+
try {
|
|
200
|
+
target = new URL(url);
|
|
201
|
+
} catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (target.protocol !== 'http:' && target.protocol !== 'https:' && target.protocol !== 'ws:' && target.protocol !== 'wss:') {
|
|
206
|
+
return !topLevelNavigation || url === 'about:blank';
|
|
207
|
+
}
|
|
208
|
+
if (this.sameOriginAsApp(target)) return true;
|
|
209
|
+
if (topLevelNavigation) return false;
|
|
210
|
+
|
|
211
|
+
const hostname = bare(target.hostname);
|
|
212
|
+
return !this.isPrivate(hostname) && !isLocalName(hostname);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// A WebSocket URL is the app's when it names the app's host and port.
|
|
216
|
+
sameOriginAsApp(target) {
|
|
217
|
+
if (target.origin === this.appOrigin) return true;
|
|
218
|
+
if (target.protocol !== 'ws:' && target.protocol !== 'wss:') return false;
|
|
219
|
+
|
|
220
|
+
const http = new URL(target.toString());
|
|
221
|
+
http.protocol = target.protocol === 'ws:' ? 'http:' : 'https:';
|
|
222
|
+
return http.origin === this.appOrigin;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Returns the address a browser connection to `hostname`:`port` is to be
|
|
227
|
+
* made to, or why it may not be made.
|
|
228
|
+
*
|
|
229
|
+
* @param {string} hostname a host name or IP address, IPv6 with or without brackets
|
|
230
|
+
* @param {number} port
|
|
231
|
+
* @returns {Promise<{ address: string, refusal: null } | { address: null, refusal: string }>}
|
|
232
|
+
*/
|
|
233
|
+
async destination(hostname, port) {
|
|
234
|
+
const host = bare(hostname);
|
|
235
|
+
if (this.isApp(host, port)) return { address: host, refusal: null };
|
|
236
|
+
|
|
237
|
+
const refused = (reason) => ({ address: null, refusal: `${host}:${port} ${reason}` });
|
|
238
|
+
if (isLocalName(host)) return refused('is a local host name');
|
|
239
|
+
if (isIP(host)) return this.isPrivate(host) ? refused('is a loopback, link-local or private address') : { address: host, refusal: null };
|
|
240
|
+
|
|
241
|
+
const addresses = await this.lookup(host);
|
|
242
|
+
if (addresses.length === 0) return refused('does not resolve');
|
|
243
|
+
if (addresses.some((address) => this.isPrivate(address))) return refused('resolves to a loopback, link-local or private address');
|
|
244
|
+
return { address: addresses[0], refusal: null };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
isApp(hostname, port) {
|
|
248
|
+
return bare(hostname) === this.appHost && port === this.appPort;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async lookup(hostname) {
|
|
252
|
+
const cached = this.resolved.get(hostname);
|
|
253
|
+
if (cached && cached.until > Date.now()) return cached.addresses;
|
|
254
|
+
|
|
255
|
+
let addresses = [];
|
|
256
|
+
try {
|
|
257
|
+
addresses = await this.resolve(hostname);
|
|
258
|
+
} catch {
|
|
259
|
+
// A name that does not resolve is refused.
|
|
260
|
+
}
|
|
261
|
+
this.resolved.set(hostname, { addresses, until: Date.now() + this.cacheMs });
|
|
262
|
+
return addresses;
|
|
263
|
+
}
|
|
264
|
+
}
|