@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,210 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
|
|
3
|
+
// The Playwright MCP tools that only look at the browser. Every other tool
|
|
4
|
+
// changes the page, and waits while a person holds control.
|
|
5
|
+
export const OBSERVING_TOOLS = new Set([
|
|
6
|
+
'browser_snapshot',
|
|
7
|
+
'browser_take_screenshot',
|
|
8
|
+
'browser_console_messages',
|
|
9
|
+
'browser_network_requests',
|
|
10
|
+
'browser_network_request',
|
|
11
|
+
'browser_find',
|
|
12
|
+
'browser_generate_locator',
|
|
13
|
+
'browser_wait_for',
|
|
14
|
+
'browser_verify_element_visible',
|
|
15
|
+
'browser_verify_list_visible',
|
|
16
|
+
'browser_verify_text_visible',
|
|
17
|
+
'browser_verify_value',
|
|
18
|
+
'browser_pdf_save',
|
|
19
|
+
'browser_get_config',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
// The _meta key a refused call's result names the person driving under.
|
|
23
|
+
export const TAKEOVER_META = 'activeagents/takeover';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether the tool call `name` with `args` would change what the browser
|
|
27
|
+
* shows.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} name
|
|
30
|
+
* @param {object} [args]
|
|
31
|
+
*/
|
|
32
|
+
export function changesPage(name, args = {}) {
|
|
33
|
+
if (name === 'browser_tabs') return args?.action !== 'list';
|
|
34
|
+
return !OBSERVING_TOOLS.has(name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether the holders' users `a` and `b` are the same person. A user with no
|
|
39
|
+
* id is nobody in particular, so is never the same as anyone.
|
|
40
|
+
*
|
|
41
|
+
* @param {{ id: string|null }} a
|
|
42
|
+
* @param {{ id: string|null }} b
|
|
43
|
+
*/
|
|
44
|
+
export function sameUser(a, b) {
|
|
45
|
+
return a.id !== null && a.id === b.id;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Used to let one person at a time drive the browser by hand. While someone
|
|
50
|
+
* holds control, an agent's call that changes the page waits for them to
|
|
51
|
+
* hand it back (#admit).
|
|
52
|
+
*
|
|
53
|
+
* A holder is identified by the key of their connection, and is a
|
|
54
|
+
* `{ key, user: { id, name }, since }`. A user with an id may move control
|
|
55
|
+
* between their own connections: a second tab, or a reconnect.
|
|
56
|
+
*
|
|
57
|
+
* Events:
|
|
58
|
+
* change (holder, previous, reason) whenever the holder changes;
|
|
59
|
+
* reason is "taken", "moved", "handed_back", "disconnected"
|
|
60
|
+
* or "closed"
|
|
61
|
+
* agent_waiting ({ tool, since }) an agent's call began to wait
|
|
62
|
+
* agent_done ({ tool, admitted }) it stopped waiting, run or refused
|
|
63
|
+
*/
|
|
64
|
+
export class ControlLock extends EventEmitter {
|
|
65
|
+
/**
|
|
66
|
+
* @param {object} [options]
|
|
67
|
+
* @param {number} [options.graceMs] how long control stays with a holder whose connection dropped
|
|
68
|
+
* @param {() => number} [options.now] epoch milliseconds
|
|
69
|
+
*/
|
|
70
|
+
constructor({ graceMs = 10_000, now = Date.now } = {}) {
|
|
71
|
+
super();
|
|
72
|
+
this.graceMs = graceMs;
|
|
73
|
+
this.now = now;
|
|
74
|
+
this.holder = null;
|
|
75
|
+
this.graceTimer = null;
|
|
76
|
+
this.waiting = new Set();
|
|
77
|
+
this.closed = false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Gives control to the connection `key`, for `user`, unless another
|
|
82
|
+
* person holds it.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} key
|
|
85
|
+
* @param {{ id: string|null, name: string|null }} user
|
|
86
|
+
* @returns {{ holder: object, taken: boolean }} taken is false when someone else holds it
|
|
87
|
+
*/
|
|
88
|
+
acquire(key, user) {
|
|
89
|
+
const previous = this.holder;
|
|
90
|
+
if (this.closed) return { holder: previous, taken: false };
|
|
91
|
+
if (previous?.key === key) return { holder: previous, taken: true };
|
|
92
|
+
if (previous && !sameUser(previous.user, user)) return { holder: previous, taken: false };
|
|
93
|
+
|
|
94
|
+
this.clearGrace();
|
|
95
|
+
this.holder = { key, user, since: previous?.since ?? this.now() };
|
|
96
|
+
this.emit('change', this.holder, previous, previous ? 'moved' : 'taken');
|
|
97
|
+
return { holder: this.holder, taken: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Takes control from the connection `key`, when it holds it.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} key
|
|
104
|
+
* @param {string} [reason]
|
|
105
|
+
* @returns {boolean} whether it held control
|
|
106
|
+
*/
|
|
107
|
+
release(key, reason = 'handed_back') {
|
|
108
|
+
if (this.holder?.key !== key) return false;
|
|
109
|
+
|
|
110
|
+
this.clearGrace();
|
|
111
|
+
const previous = this.holder;
|
|
112
|
+
this.holder = null;
|
|
113
|
+
this.emit('change', null, previous, reason);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Notes that the connection `key` closed: when it holds control, control
|
|
119
|
+
* is released after the grace period, unless its user takes it up again
|
|
120
|
+
* from another connection first.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} key
|
|
123
|
+
*/
|
|
124
|
+
disconnected(key) {
|
|
125
|
+
if (this.holder?.key !== key) return;
|
|
126
|
+
|
|
127
|
+
this.clearGrace();
|
|
128
|
+
this.graceTimer = setTimeout(() => this.release(key, 'disconnected'), this.graceMs);
|
|
129
|
+
this.graceTimer.unref?.();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Lets an agent's tool call through, at once when it does not change the
|
|
134
|
+
* page or nobody holds control, and otherwise once control is handed back
|
|
135
|
+
* within `waitMs`.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} name the tool
|
|
138
|
+
* @param {object} args its arguments
|
|
139
|
+
* @param {number} waitMs
|
|
140
|
+
* @returns {Promise<{ holder: object }|{ stopping: true }|null>} null to run the call; otherwise why it
|
|
141
|
+
* is refused: the holder who kept control past `waitMs`, or `stopping` when the lock closed meanwhile
|
|
142
|
+
*/
|
|
143
|
+
async admit(name, args, waitMs) {
|
|
144
|
+
if (!this.holder || !changesPage(name, args)) return null;
|
|
145
|
+
|
|
146
|
+
const wait = { tool: name, since: this.now() };
|
|
147
|
+
const deadline = wait.since + waitMs;
|
|
148
|
+
this.waiting.add(wait);
|
|
149
|
+
this.emit('agent_waiting', wait);
|
|
150
|
+
// Someone may take control again between its release and this call
|
|
151
|
+
// going ahead, so the wait goes on until control is free at that moment.
|
|
152
|
+
while (this.holder && !this.closed && deadline > this.now()) {
|
|
153
|
+
if (!(await this.waitUntilFree(deadline - this.now()))) break;
|
|
154
|
+
}
|
|
155
|
+
let refusal = null;
|
|
156
|
+
if (this.closed) refusal = { stopping: true };
|
|
157
|
+
else if (this.holder) refusal = { holder: this.holder };
|
|
158
|
+
this.waiting.delete(wait);
|
|
159
|
+
this.emit('agent_done', { tool: name, admitted: refusal === null });
|
|
160
|
+
return refusal;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Resolves true once nobody holds control, or false after `timeoutMs`.
|
|
165
|
+
*
|
|
166
|
+
* @param {number} timeoutMs
|
|
167
|
+
* @returns {Promise<boolean>}
|
|
168
|
+
*/
|
|
169
|
+
waitUntilFree(timeoutMs) {
|
|
170
|
+
if (!this.holder) return Promise.resolve(true);
|
|
171
|
+
if (this.closed) return Promise.resolve(false);
|
|
172
|
+
|
|
173
|
+
return new Promise((resolve) => {
|
|
174
|
+
const finish = (free) => {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
this.off('change', onChange);
|
|
177
|
+
this.off('closed', onClosed);
|
|
178
|
+
resolve(free);
|
|
179
|
+
};
|
|
180
|
+
const onChange = (holder) => {
|
|
181
|
+
if (!holder) finish(true);
|
|
182
|
+
};
|
|
183
|
+
const onClosed = () => finish(false);
|
|
184
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
185
|
+
this.on('change', onChange);
|
|
186
|
+
this.on('closed', onClosed);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The longest-waiting agent call, or null.
|
|
191
|
+
get agentWaiting() {
|
|
192
|
+
const [first] = this.waiting;
|
|
193
|
+
return first ?? null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Releases control for good, refusing the agent calls still waiting: the
|
|
197
|
+
// browser is stopping.
|
|
198
|
+
close() {
|
|
199
|
+
if (this.closed) return;
|
|
200
|
+
this.closed = true;
|
|
201
|
+
this.emit('closed');
|
|
202
|
+
if (this.holder) this.release(this.holder.key, 'closed');
|
|
203
|
+
this.clearGrace();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
clearGrace() {
|
|
207
|
+
if (this.graceTimer !== null) clearTimeout(this.graceTimer);
|
|
208
|
+
this.graceTimer = null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
|
|
4
|
+
// Headers that belong to one hop and are never passed across the proxy.
|
|
5
|
+
const HOP_BY_HOP = new Set([
|
|
6
|
+
'connection', 'proxy-connection', 'keep-alive', 'proxy-authorization', 'proxy-authenticate', 'te', 'trailer', 'transfer-encoding', 'upgrade',
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
function endToEndHeaders(headers) {
|
|
10
|
+
const named = String(headers.connection ?? '').toLowerCase().split(',').map((name) => name.trim());
|
|
11
|
+
return Object.fromEntries(Object.entries(headers).filter(([name]) => !HOP_BY_HOP.has(name) && !named.includes(name)));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// "example.com:443" or "[::1]:8080" as a host and port, or null.
|
|
15
|
+
function authority(text) {
|
|
16
|
+
const match = /^(\[[^\]]+\]|[^:]+):(\d{1,5})$/.exec(text ?? '');
|
|
17
|
+
if (!match) return null;
|
|
18
|
+
const port = Number(match[2]);
|
|
19
|
+
return port > 0 && port < 65536 ? { hostname: match[1], port } : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function refuse(response, status, reason) {
|
|
23
|
+
response.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', connection: 'close' });
|
|
24
|
+
response.end(`Blocked by the sandbox browser: ${reason}\n`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Starts the forward proxy every connection the browser opens goes through,
|
|
29
|
+
* and returns once it listens on loopback. Each plain HTTP request and each
|
|
30
|
+
* CONNECT tunnel (HTTPS and WebSockets) is checked with
|
|
31
|
+
* `policy.destination`, and the upstream connection is made to the address
|
|
32
|
+
* that check returned, never to a name resolved again. A refused request is
|
|
33
|
+
* answered 403; a refused tunnel is never opened.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} options
|
|
36
|
+
* @param {{ destination(hostname: string, port: number): Promise<{ address: string | null, refusal: string | null }> }} options.policy
|
|
37
|
+
* @param {(message: string) => void} [options.log]
|
|
38
|
+
* @returns {Promise<{ url: string, close: () => Promise<void> }>}
|
|
39
|
+
*/
|
|
40
|
+
export async function startEgressProxy({ policy, log = () => {} }) {
|
|
41
|
+
const agent = new http.Agent({ keepAlive: true });
|
|
42
|
+
const sockets = new Set();
|
|
43
|
+
const track = (socket) => {
|
|
44
|
+
sockets.add(socket);
|
|
45
|
+
socket.once('close', () => sockets.delete(socket));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const server = http.createServer(async (request, response) => {
|
|
49
|
+
let target;
|
|
50
|
+
try {
|
|
51
|
+
target = new URL(request.url);
|
|
52
|
+
} catch {
|
|
53
|
+
return refuse(response, 400, 'not a proxy request');
|
|
54
|
+
}
|
|
55
|
+
if (target.protocol !== 'http:') return refuse(response, 400, `${target.protocol} requests are not proxied`);
|
|
56
|
+
|
|
57
|
+
const port = Number(target.port || 80);
|
|
58
|
+
const { address, refusal } = await policy.destination(target.hostname, port);
|
|
59
|
+
if (refusal) {
|
|
60
|
+
log(`refused a request: ${refusal}`);
|
|
61
|
+
return refuse(response, 403, refusal);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const upstream = http.request({
|
|
65
|
+
host: address,
|
|
66
|
+
port,
|
|
67
|
+
method: request.method,
|
|
68
|
+
path: `${target.pathname}${target.search}`,
|
|
69
|
+
headers: endToEndHeaders(request.headers),
|
|
70
|
+
setHost: false,
|
|
71
|
+
agent,
|
|
72
|
+
});
|
|
73
|
+
upstream.on('response', (answer) => {
|
|
74
|
+
response.writeHead(answer.statusCode, answer.statusMessage, endToEndHeaders(answer.headers));
|
|
75
|
+
answer.pipe(response);
|
|
76
|
+
});
|
|
77
|
+
upstream.on('error', (error) => {
|
|
78
|
+
if (response.headersSent) response.destroy();
|
|
79
|
+
else refuse(response, 502, `${target.host} could not be reached (${error.code ?? error.message})`);
|
|
80
|
+
});
|
|
81
|
+
response.on('close', () => {
|
|
82
|
+
if (!response.writableFinished) upstream.destroy();
|
|
83
|
+
});
|
|
84
|
+
request.pipe(upstream);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
server.on('connection', track);
|
|
88
|
+
server.on('connect', async (request, socket, head) => {
|
|
89
|
+
socket.on('error', () => {});
|
|
90
|
+
const requested = authority(request.url);
|
|
91
|
+
const { address, refusal } = requested
|
|
92
|
+
? await policy.destination(requested.hostname, requested.port)
|
|
93
|
+
: { address: null, refusal: `${request.url} is not a host and port` };
|
|
94
|
+
if (refusal) {
|
|
95
|
+
log(`refused a connection: ${refusal}`);
|
|
96
|
+
socket.end('HTTP/1.1 403 Forbidden\r\ncontent-length: 0\r\nconnection: close\r\n\r\n');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const upstream = net.connect(requested.port, address);
|
|
101
|
+
track(upstream);
|
|
102
|
+
upstream.on('error', () => socket.destroy());
|
|
103
|
+
socket.on('close', () => upstream.destroy());
|
|
104
|
+
upstream.once('connect', () => {
|
|
105
|
+
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
106
|
+
if (head.length > 0) upstream.write(head);
|
|
107
|
+
upstream.pipe(socket);
|
|
108
|
+
socket.pipe(upstream);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await new Promise((resolve, reject) => {
|
|
113
|
+
server.once('error', reject);
|
|
114
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
url: `http://127.0.0.1:${server.address().port}`,
|
|
119
|
+
close: async () => {
|
|
120
|
+
await new Promise((resolve) => {
|
|
121
|
+
server.close(() => resolve());
|
|
122
|
+
for (const socket of sockets) socket.destroy();
|
|
123
|
+
});
|
|
124
|
+
agent.destroy();
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
package/lib/guard.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const LOOPBACK_NAMES = ['127.0.0.1', 'localhost', '[::1]'];
|
|
4
|
+
|
|
5
|
+
function hostName(host) {
|
|
6
|
+
return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Returns the Host header values a request to the sidecar may carry: the
|
|
11
|
+
* address it listens on and, when that is loopback or every interface, the
|
|
12
|
+
* loopback names, each with the port; and `extra` as given (the names the
|
|
13
|
+
* sidecar is reached by inside a container network).
|
|
14
|
+
*
|
|
15
|
+
* @param {string} host the listening address
|
|
16
|
+
* @param {number} port the listening port
|
|
17
|
+
* @param {string[]} extra Host values to accept as given
|
|
18
|
+
* @returns {Set<string>}
|
|
19
|
+
*/
|
|
20
|
+
export function acceptedHosts(host, port, extra = []) {
|
|
21
|
+
const names = new Set([hostName(host.toLowerCase())]);
|
|
22
|
+
if (['127.0.0.1', 'localhost', '::1', '0.0.0.0', '::'].includes(host)) {
|
|
23
|
+
for (const name of LOOPBACK_NAMES) names.add(name);
|
|
24
|
+
}
|
|
25
|
+
names.delete('0.0.0.0');
|
|
26
|
+
names.delete('[::]');
|
|
27
|
+
|
|
28
|
+
const accepted = new Set([...names].map((name) => `${name}:${port}`));
|
|
29
|
+
for (const value of extra) accepted.add(value.toLowerCase());
|
|
30
|
+
return accepted;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function digest(value) {
|
|
34
|
+
return createHash('sha256').update(value).digest();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function bearerMatches(header, token) {
|
|
38
|
+
const match = /^Bearer\s+(\S+)\s*$/i.exec(header ?? '');
|
|
39
|
+
return match !== null && timingSafeEqual(digest(match[1]), digest(token));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Checks one HTTP request, or WebSocket upgrade, before anything else reads
|
|
44
|
+
* it. A request must name an accepted Host, which defeats DNS rebinding,
|
|
45
|
+
* carry no Origin unless that origin is allowed (a page in a browser always
|
|
46
|
+
* sends one), and carry the bearer token.
|
|
47
|
+
*
|
|
48
|
+
* @param {import('node:http').IncomingMessage} request
|
|
49
|
+
* @param {{ token: string, hosts: Set<string>, origins?: Set<string> }} rules
|
|
50
|
+
* @returns {{ status: number, message: string } | null} the refusal, or null to proceed
|
|
51
|
+
*/
|
|
52
|
+
export function refusal(request, { token, hosts, origins = new Set() }) {
|
|
53
|
+
const host = (request.headers.host ?? '').toLowerCase();
|
|
54
|
+
if (!hosts.has(host)) return { status: 403, message: 'Host not allowed' };
|
|
55
|
+
|
|
56
|
+
const origin = request.headers.origin;
|
|
57
|
+
if (origin !== undefined && !origins.has(origin)) return { status: 403, message: 'Origin not allowed' };
|
|
58
|
+
|
|
59
|
+
if (!bearerMatches(request.headers.authorization, token)) return { status: 401, message: 'Missing or invalid bearer token' };
|
|
60
|
+
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Checks a WebSocket upgrade to the live view before it is accepted. The
|
|
66
|
+
* dashboard's page opens it, so it carries the dashboard's Origin, and it
|
|
67
|
+
* carries no token: a viewer proves who they are with the ticket in its
|
|
68
|
+
* first message (LiveServer). The request must name an accepted Host, come
|
|
69
|
+
* from one of `origins`, and carry no query, which keeps a ticket out of
|
|
70
|
+
* URLs and the logs that record them.
|
|
71
|
+
*
|
|
72
|
+
* @param {import('node:http').IncomingMessage} request
|
|
73
|
+
* @param {{ hosts: Set<string>, origins: Set<string> }} rules
|
|
74
|
+
* @returns {{ status: number, message: string } | null} the refusal, or null to proceed
|
|
75
|
+
*/
|
|
76
|
+
export function liveRefusal(request, { hosts, origins }) {
|
|
77
|
+
const host = (request.headers.host ?? '').toLowerCase();
|
|
78
|
+
if (!hosts.has(host)) return { status: 403, message: 'Host not allowed' };
|
|
79
|
+
|
|
80
|
+
const origin = request.headers.origin;
|
|
81
|
+
if (typeof origin !== 'string' || !origins.has(origin.toLowerCase())) return { status: 403, message: 'Origin not allowed' };
|
|
82
|
+
|
|
83
|
+
if (request.url.includes('?')) return { status: 400, message: 'The live view takes no query' };
|
|
84
|
+
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
|
|
3
|
+
import { liveRefusal, refusal } from './guard.mjs';
|
|
4
|
+
import { LIVE_PATH } from './live-server.mjs';
|
|
5
|
+
|
|
6
|
+
// Larger than any tool call the engine sends.
|
|
7
|
+
export const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
function respond(response, status, body, headers = {}) {
|
|
10
|
+
const text = body === undefined ? '' : JSON.stringify(body);
|
|
11
|
+
response.writeHead(status, {
|
|
12
|
+
'content-type': 'application/json',
|
|
13
|
+
'cache-control': 'no-store',
|
|
14
|
+
'content-length': Buffer.byteLength(text),
|
|
15
|
+
...headers,
|
|
16
|
+
});
|
|
17
|
+
response.end(text);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readBody(request) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const chunks = [];
|
|
23
|
+
let size = 0;
|
|
24
|
+
request.on('data', (chunk) => {
|
|
25
|
+
size += chunk.length;
|
|
26
|
+
if (size > MAX_BODY_BYTES) {
|
|
27
|
+
reject(Object.assign(new Error('The body is too large'), { status: 413 }));
|
|
28
|
+
request.destroy();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
chunks.push(chunk);
|
|
32
|
+
});
|
|
33
|
+
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
34
|
+
request.on('error', reject);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pathOf(request) {
|
|
39
|
+
try {
|
|
40
|
+
return new URL(request.url, 'http://sidecar').pathname;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function rejectUpgrade(socket, status) {
|
|
47
|
+
socket.end(`HTTP/1.1 ${status} ${http.STATUS_CODES[status]}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Creates the sidecar's HTTP server:
|
|
52
|
+
*
|
|
53
|
+
* GET /health { status: "ok", version }
|
|
54
|
+
* POST /mcp one JSON-RPC message, answered as JSON (McpGateway)
|
|
55
|
+
* DELETE /mcp ends the session named by Mcp-Session-Id
|
|
56
|
+
* GET /live the live view's WebSocket (LiveServer), when `live` is given
|
|
57
|
+
* GET /storage-state the browser's cookies and localStorage for the
|
|
58
|
+
* app's origin, so a sign-in can be kept
|
|
59
|
+
*
|
|
60
|
+
* Every request, and every WebSocket upgrade other than the live view's, is
|
|
61
|
+
* checked by guard.refusal before anything else happens. The live view's
|
|
62
|
+
* upgrade is checked by guard.liveRefusal instead, and any other upgrade is
|
|
63
|
+
* refused.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} options
|
|
66
|
+
* @param {() => { token: string, hosts: Set<string>, origins?: Set<string>, liveOrigins?: Set<string> }} options.rules
|
|
67
|
+
* what the guards check against, read per request since the port is known only once listening
|
|
68
|
+
* @param {{ handle: Function, closeSession: Function }} options.gateway
|
|
69
|
+
* @param {string} options.version
|
|
70
|
+
* @param {{ handleUpgrade: Function } | null} [options.live] a LiveServer
|
|
71
|
+
* @param {() => Promise<object>} [options.storageState] returns what GET /storage-state answers
|
|
72
|
+
* @returns {import('node:http').Server}
|
|
73
|
+
*/
|
|
74
|
+
export function createHttpServer({ rules, gateway, version, live = null, storageState = null }) {
|
|
75
|
+
const server = http.createServer(async (request, response) => {
|
|
76
|
+
const denied = refusal(request, rules());
|
|
77
|
+
if (denied) return respond(response, denied.status, { error: denied.message });
|
|
78
|
+
|
|
79
|
+
const path = new URL(request.url, 'http://sidecar').pathname;
|
|
80
|
+
try {
|
|
81
|
+
if (path === '/health' && request.method === 'GET') return respond(response, 200, { status: 'ok', version });
|
|
82
|
+
if (path === '/storage-state' && request.method === 'GET' && storageState) {
|
|
83
|
+
return respond(response, 200, { storage_state: await storageState() });
|
|
84
|
+
}
|
|
85
|
+
if (path !== '/mcp') return respond(response, 404, { error: 'Not found' });
|
|
86
|
+
|
|
87
|
+
const sessionId = request.headers['mcp-session-id'];
|
|
88
|
+
if (request.method === 'DELETE') {
|
|
89
|
+
const closed = typeof sessionId === 'string' && (await gateway.closeSession(sessionId));
|
|
90
|
+
return respond(response, closed ? 200 : 404, closed ? { closed: true } : { error: 'Session not found' });
|
|
91
|
+
}
|
|
92
|
+
if (request.method !== 'POST') return respond(response, 405, { error: 'POST JSON-RPC here' }, { allow: 'POST, DELETE' });
|
|
93
|
+
|
|
94
|
+
let message;
|
|
95
|
+
try {
|
|
96
|
+
message = JSON.parse(await readBody(request));
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error.status) throw error;
|
|
99
|
+
return respond(response, 400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'The body is not JSON' } });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const result = await gateway.handle(message, typeof sessionId === 'string' ? sessionId : undefined);
|
|
103
|
+
return respond(response, result.status, result.body, result.headers);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (!response.headersSent) respond(response, error.status ?? 500, { error: error.status ? error.message : 'Internal error' });
|
|
106
|
+
if (!error.status) process.stderr.write(`browser-sidecar: ${error.stack ?? error}\n`);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
server.on('upgrade', (request, socket, head) => {
|
|
111
|
+
const current = rules();
|
|
112
|
+
if (live && pathOf(request) === LIVE_PATH) {
|
|
113
|
+
const denied = liveRefusal(request, { hosts: current.hosts, origins: current.liveOrigins ?? new Set() });
|
|
114
|
+
if (denied) rejectUpgrade(socket, denied.status);
|
|
115
|
+
else live.handleUpgrade(request, socket, head);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
rejectUpgrade(socket, (refusal(request, current) ?? { status: 404 }).status);
|
|
120
|
+
});
|
|
121
|
+
server.on('clientError', (_error, socket) => socket.destroy());
|
|
122
|
+
|
|
123
|
+
return server;
|
|
124
|
+
}
|
package/lib/input.mjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Turns the input a viewer relays into Chrome DevTools Protocol commands for
|
|
2
|
+
// the page on screen. A viewer sends pointer positions as fractions of the
|
|
3
|
+
// frame it was shown (0 to 1 from the left and top), and they are scaled to
|
|
4
|
+
// the page's CSS pixels from the latest frame's metadata.
|
|
5
|
+
//
|
|
6
|
+
// Messages:
|
|
7
|
+
// { type: "mouse", action: "move"|"down"|"up", x, y, button, clickCount, modifiers }
|
|
8
|
+
// { type: "wheel", x, y, deltaX, deltaY, modifiers }
|
|
9
|
+
// { type: "key", action: "down"|"up", key, code, keyCode, modifiers }
|
|
10
|
+
// { type: "text", text }
|
|
11
|
+
//
|
|
12
|
+
// `modifiers` is the CDP bit field: Alt 1, Ctrl 2, Meta 4, Shift 8.
|
|
13
|
+
|
|
14
|
+
const MOUSE_TYPES = { move: 'mouseMoved', down: 'mousePressed', up: 'mouseReleased' };
|
|
15
|
+
const BUTTONS = new Set(['none', 'left', 'middle', 'right']);
|
|
16
|
+
const CTRL = 2;
|
|
17
|
+
const META = 4;
|
|
18
|
+
const MAX_KEY_CHARS = 32;
|
|
19
|
+
const MAX_TEXT_CHARS = 2000;
|
|
20
|
+
const MAX_WHEEL_DELTA = 10_000;
|
|
21
|
+
// The text a key types when its key value is not the character itself.
|
|
22
|
+
const KEY_TEXT = { Enter: '\r' };
|
|
23
|
+
// Windows virtual key codes, for a key the viewer sent none for.
|
|
24
|
+
const KEY_CODES = {
|
|
25
|
+
Backspace: 8, Tab: 9, Enter: 13, Shift: 16, Control: 17, Alt: 18, Pause: 19, CapsLock: 20, Escape: 27, ' ': 32,
|
|
26
|
+
PageUp: 33, PageDown: 34, End: 35, Home: 36, ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40,
|
|
27
|
+
Insert: 45, Delete: 46, Meta: 91,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function fraction(value) {
|
|
31
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function modifiers(value) {
|
|
35
|
+
return Number.isInteger(value) && value >= 0 && value <= 15 ? value : 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function boundedNumber(value, limit) {
|
|
39
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
|
|
40
|
+
return Math.max(-limit, Math.min(limit, value));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Returns the CSS pixel position of a point given as fractions of a frame
|
|
45
|
+
* whose metadata is `metadata`, or null without a frame to scale by.
|
|
46
|
+
*
|
|
47
|
+
* @param {{ x: number, y: number }} point
|
|
48
|
+
* @param {{ deviceWidth: number, deviceHeight: number, offsetTop?: number } | null} metadata
|
|
49
|
+
* @returns {{ x: number, y: number } | null}
|
|
50
|
+
*/
|
|
51
|
+
export function pagePoint({ x, y }, metadata) {
|
|
52
|
+
if (!fraction(x) || !fraction(y) || !metadata) return null;
|
|
53
|
+
const { deviceWidth, deviceHeight, offsetTop = 0 } = metadata;
|
|
54
|
+
if (!(deviceWidth > 0) || !(deviceHeight > 0)) return null;
|
|
55
|
+
|
|
56
|
+
return { x: Math.round(x * deviceWidth), y: Math.round(y * deviceHeight + offsetTop) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns `[method, params]` for one relayed input message, or null when the
|
|
61
|
+
* message is not one the viewer may send, or a pointer message arrives
|
|
62
|
+
* before any frame was shown.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} message
|
|
65
|
+
* @param {object | null} metadata the latest frame's metadata
|
|
66
|
+
* @returns {[string, object] | null}
|
|
67
|
+
*/
|
|
68
|
+
export function inputCommand(message, metadata) {
|
|
69
|
+
switch (message?.type) {
|
|
70
|
+
case 'mouse':
|
|
71
|
+
return mouseCommand(message, metadata);
|
|
72
|
+
case 'wheel':
|
|
73
|
+
return wheelCommand(message, metadata);
|
|
74
|
+
case 'key':
|
|
75
|
+
return keyCommand(message);
|
|
76
|
+
case 'text':
|
|
77
|
+
return textCommand(message);
|
|
78
|
+
default:
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function mouseCommand(message, metadata) {
|
|
84
|
+
const type = MOUSE_TYPES[message.action];
|
|
85
|
+
const point = pagePoint(message, metadata);
|
|
86
|
+
if (!type || !point) return null;
|
|
87
|
+
|
|
88
|
+
const button = BUTTONS.has(message.button) ? message.button : type === 'mouseMoved' ? 'none' : 'left';
|
|
89
|
+
const clickCount = Number.isInteger(message.clickCount) && message.clickCount >= 0 && message.clickCount <= 3 ? message.clickCount : 1;
|
|
90
|
+
const params = { type, ...point, button, modifiers: modifiers(message.modifiers) };
|
|
91
|
+
if (type !== 'mouseMoved') params.clickCount = clickCount;
|
|
92
|
+
return ['Input.dispatchMouseEvent', params];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function wheelCommand(message, metadata) {
|
|
96
|
+
const point = pagePoint(message, metadata);
|
|
97
|
+
if (!point) return null;
|
|
98
|
+
|
|
99
|
+
return ['Input.dispatchMouseEvent', {
|
|
100
|
+
type: 'mouseWheel',
|
|
101
|
+
...point,
|
|
102
|
+
deltaX: boundedNumber(message.deltaX, MAX_WHEEL_DELTA),
|
|
103
|
+
deltaY: boundedNumber(message.deltaY, MAX_WHEEL_DELTA),
|
|
104
|
+
modifiers: modifiers(message.modifiers),
|
|
105
|
+
}];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The text a key down types: the character of a printable key, or Enter's
|
|
109
|
+
// carriage return. A key pressed with Ctrl or Meta is a shortcut and types
|
|
110
|
+
// nothing.
|
|
111
|
+
function keyText(key, mask) {
|
|
112
|
+
if (mask & (CTRL | META)) return '';
|
|
113
|
+
if (KEY_TEXT[key]) return KEY_TEXT[key];
|
|
114
|
+
return [...key].length === 1 ? key : '';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function keyCommand(message) {
|
|
118
|
+
const { action, key } = message;
|
|
119
|
+
if ((action !== 'down' && action !== 'up') || typeof key !== 'string' || key === '' || key.length > MAX_KEY_CHARS) return null;
|
|
120
|
+
|
|
121
|
+
const code = typeof message.code === 'string' && message.code.length <= MAX_KEY_CHARS ? message.code : '';
|
|
122
|
+
const mask = modifiers(message.modifiers);
|
|
123
|
+
let keyCode = Number.isInteger(message.keyCode) && message.keyCode > 0 && message.keyCode < 256 ? message.keyCode : KEY_CODES[key];
|
|
124
|
+
if (keyCode === undefined && /^[a-z0-9]$/i.test(key)) keyCode = key.toUpperCase().charCodeAt(0);
|
|
125
|
+
|
|
126
|
+
const params = { key, code, modifiers: mask, windowsVirtualKeyCode: keyCode ?? 0 };
|
|
127
|
+
if (action === 'up') return ['Input.dispatchKeyEvent', { type: 'keyUp', ...params }];
|
|
128
|
+
|
|
129
|
+
const text = keyText(key, mask);
|
|
130
|
+
return ['Input.dispatchKeyEvent', text ? { type: 'keyDown', ...params, text, unmodifiedText: text } : { type: 'rawKeyDown', ...params }];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function textCommand(message) {
|
|
134
|
+
if (typeof message.text !== 'string' || message.text === '' || message.text.length > MAX_TEXT_CHARS) return null;
|
|
135
|
+
|
|
136
|
+
return ['Input.insertText', { text: message.text }];
|
|
137
|
+
}
|