@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.
@@ -0,0 +1,281 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import { WebSocket, WebSocketServer } from 'ws';
4
+
5
+ import { sameUser } from './control-lock.mjs';
6
+ import { inputCommand } from './input.mjs';
7
+
8
+ export const LIVE_PATH = '/live';
9
+
10
+ // The close codes a viewer is told why it was disconnected with. The 4xxx
11
+ // codes are this protocol's own.
12
+ export const CLOSE_CODES = Object.freeze({
13
+ stopping: 1001,
14
+ unauthorized: 4401,
15
+ authTimeout: 4408,
16
+ full: 4429,
17
+ });
18
+
19
+ const AUTH_TIMEOUT_MS = 5000;
20
+ const MAX_MESSAGE_BYTES = 64 * 1024;
21
+ const MAX_CONNECTIONS = 8;
22
+ // A viewer whose socket holds more than this unsent misses frames until it
23
+ // has caught up, and is then sent the latest one.
24
+ const MAX_BUFFERED_BYTES = 2 * 1024 * 1024;
25
+ const CATCH_UP_MS = 250;
26
+ const TERMINATE_AFTER_MS = 1000;
27
+ const INPUT_TYPES = new Set(['mouse', 'wheel', 'key', 'text']);
28
+
29
+ function parse(data, isBinary) {
30
+ if (isBinary) return null;
31
+ try {
32
+ const message = JSON.parse(data.toString('utf8'));
33
+ return message && typeof message === 'object' && !Array.isArray(message) ? message : null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Used to serve the live view: the page on screen as a stream of frames to
41
+ * everyone watching, and the input of the one person who holds control
42
+ * (ControlLock) relayed to it.
43
+ *
44
+ * A connection proves who it is with the ticket in its first message
45
+ * (TicketVerifier), and is closed with CLOSE_CODES.unauthorized, before
46
+ * anything is sent to it, when that message is anything else. Either mode of
47
+ * ticket lets it watch. Each request to take control carries a control
48
+ * ticket of its own, issued to the same user.
49
+ *
50
+ * Viewer to sidecar, as JSON text:
51
+ * { type: "auth", ticket } first, and only first
52
+ * { type: "take_control", ticket } a control ticket issued to the user the connection authenticated as
53
+ * { type: "hand_back" }
54
+ * mouse, wheel, key and text input see input.mjs; dropped unless this connection holds control
55
+ *
56
+ * Sidecar to viewer:
57
+ * { type: "ready", control, agent, page } once the ticket is accepted
58
+ * { type: "frame", data, width, height } a JPEG as base64, and the page's size in CSS pixels
59
+ * { type: "control", held, mine, yours, by, since } who holds control, by name; mine when this
60
+ * connection does, yours when its user does on any
61
+ * { type: "agent", waiting, tool, since } an agent's call is waiting for control to be handed back
62
+ * { type: "page", page: { url, tab, tabs } } the page on screen changed
63
+ * { type: "error", code, message } a request this connection may not make
64
+ *
65
+ * Relayed input is never logged or recorded. Taking and handing back
66
+ * control are, through `onMarker`.
67
+ */
68
+ export class LiveServer {
69
+ /**
70
+ * @param {object} options
71
+ * @param {{ verify(ticket: unknown): object }} options.verifier a TicketVerifier
72
+ * @param {import('./control-lock.mjs').ControlLock} options.lock
73
+ * @param {import('./screencast.mjs').Screencast} options.screencast
74
+ * @param {(data: object) => void} [options.onMarker] given a takeover's start and end, to record
75
+ * @param {(message: string) => void} [options.log]
76
+ * @param {number} [options.authTimeoutMs] how long a connection may take to send its ticket
77
+ * @param {number} [options.maxConnections]
78
+ */
79
+ constructor({ verifier, lock, screencast, onMarker = () => {}, log = () => {}, authTimeoutMs = AUTH_TIMEOUT_MS, maxConnections = MAX_CONNECTIONS }) {
80
+ this.verifier = verifier;
81
+ this.lock = lock;
82
+ this.screencast = screencast;
83
+ this.onMarker = onMarker;
84
+ this.log = log;
85
+ this.authTimeoutMs = authTimeoutMs;
86
+ this.maxConnections = maxConnections;
87
+ this.wss = new WebSocketServer({ noServer: true, maxPayload: MAX_MESSAGE_BYTES, perMessageDeflate: false });
88
+ this.connections = new Set();
89
+ this.viewers = new Map();
90
+ this.catchUp = null;
91
+ this.closed = false;
92
+
93
+ lock.on('change', (holder, previous, reason) => this.controlChanged(holder, previous, reason));
94
+ lock.on('agent_waiting', () => this.broadcast({ type: 'agent', ...this.agentState() }));
95
+ lock.on('agent_done', () => this.broadcast({ type: 'agent', ...this.agentState() }));
96
+ }
97
+
98
+ /**
99
+ * Accepts a WebSocket upgrade that guard.liveRefusal let through.
100
+ *
101
+ * @param {import('node:http').IncomingMessage} request
102
+ * @param {import('node:stream').Duplex} socket
103
+ * @param {Buffer} head
104
+ */
105
+ handleUpgrade(request, socket, head) {
106
+ if (this.closed) {
107
+ socket.destroy();
108
+ return;
109
+ }
110
+ this.wss.handleUpgrade(request, socket, head, (ws) => this.accept(ws));
111
+ }
112
+
113
+ accept(ws) {
114
+ ws.on('error', () => {});
115
+ if (this.connections.size >= this.maxConnections) {
116
+ ws.close(CLOSE_CODES.full, 'Too many viewers');
117
+ return;
118
+ }
119
+
120
+ this.connections.add(ws);
121
+ const timer = setTimeout(() => ws.close(CLOSE_CODES.authTimeout, 'No ticket'), this.authTimeoutMs);
122
+ timer.unref?.();
123
+ ws.once('message', (data, isBinary) => {
124
+ clearTimeout(timer);
125
+ this.authenticate(ws, parse(data, isBinary));
126
+ });
127
+ ws.on('close', () => {
128
+ clearTimeout(timer);
129
+ this.connections.delete(ws);
130
+ this.leave(ws);
131
+ });
132
+ }
133
+
134
+ authenticate(ws, message) {
135
+ const result = message?.type === 'auth' ? this.verifier.verify(message.ticket) : { refusal: 'no ticket first' };
136
+ if (result.refusal) {
137
+ this.log(`live view: refused a viewer (${result.refusal})`);
138
+ ws.close(CLOSE_CODES.unauthorized, result.refusal);
139
+ return;
140
+ }
141
+
142
+ const { sub, name } = result.claims;
143
+ const viewer = { id: randomUUID(), ws, user: { id: sub, name }, behind: false };
144
+ this.viewers.set(ws, viewer);
145
+ ws.on('message', (data, isBinary) => this.receive(viewer, parse(data, isBinary)));
146
+
147
+ this.send(viewer, { type: 'ready', control: this.controlState(viewer), agent: this.agentState(), page: this.screencast.pageInfo });
148
+ if (this.screencast.lastFrame) this.sendFrame(viewer, JSON.stringify(frameMessage(this.screencast.lastFrame)));
149
+ void this.screencast.start();
150
+ }
151
+
152
+ receive(viewer, message) {
153
+ if (!message) return;
154
+
155
+ if (message.type === 'take_control') this.takeControl(viewer, message.ticket);
156
+ else if (message.type === 'hand_back') this.lock.release(viewer.id);
157
+ else if (INPUT_TYPES.has(message.type)) this.relay(viewer, message);
158
+ }
159
+
160
+ // A ticket per request, rather than one per connection, means a person
161
+ // whose permission is withdrawn cannot take control again on a connection
162
+ // they opened before.
163
+ takeControl(viewer, ticket) {
164
+ const { claims } = this.verifier.verify(ticket);
165
+ if (claims?.mode !== 'control' || claims.sub !== viewer.user.id) {
166
+ this.send(viewer, { type: 'error', code: 'view_only', message: 'Taking over this browser needs a control ticket issued to you' });
167
+ return;
168
+ }
169
+
170
+ const { taken, holder } = this.lock.acquire(viewer.id, viewer.user);
171
+ if (!taken) this.send(viewer, { type: 'error', code: 'held', message: `${holder?.user.name ?? 'Someone else'} is driving the browser` });
172
+ }
173
+
174
+ relay(viewer, message) {
175
+ if (this.lock.holder?.key !== viewer.id) return;
176
+
177
+ const command = inputCommand(message, this.screencast.metadata);
178
+ if (!command) return;
179
+ this.screencast.dispatch(...command).catch(() => this.log(`live view: ${command[0]} was not delivered`));
180
+ }
181
+
182
+ leave(ws) {
183
+ const viewer = this.viewers.get(ws);
184
+ if (!viewer) return;
185
+
186
+ this.viewers.delete(ws);
187
+ this.lock.disconnected(viewer.id);
188
+ if (this.viewers.size === 0) void this.screencast.stop();
189
+ }
190
+
191
+ controlState(viewer) {
192
+ const holder = this.lock.holder;
193
+ if (!holder) return { held: false, mine: false, yours: false, by: null, since: null };
194
+
195
+ const mine = holder.key === viewer.id;
196
+ return { held: true, mine, yours: mine || sameUser(holder.user, viewer.user), by: holder.user.name, since: holder.since };
197
+ }
198
+
199
+ agentState() {
200
+ const waiting = this.lock.agentWaiting;
201
+ return waiting ? { waiting: true, tool: waiting.tool, since: waiting.since } : { waiting: false, tool: null, since: null };
202
+ }
203
+
204
+ // Control moving between connections of one user is no takeover of its own.
205
+ controlChanged(holder, previous, reason) {
206
+ for (const viewer of this.viewers.values()) this.send(viewer, { type: 'control', ...this.controlState(viewer) });
207
+
208
+ if (holder && !previous) this.onMarker({ label: 'takeover_started', source: 'human', user: holder.user });
209
+ else if (!holder && previous) this.onMarker({ label: 'takeover_ended', source: 'human', user: previous.user, reason });
210
+ }
211
+
212
+ /**
213
+ * Sends a frame the screencast produced to everyone watching.
214
+ *
215
+ * @param {{ data: string, metadata: object }} frame
216
+ */
217
+ frame(frame) {
218
+ const text = JSON.stringify(frameMessage(frame));
219
+ for (const viewer of this.viewers.values()) this.sendFrame(viewer, text);
220
+ }
221
+
222
+ // Tells everyone watching which page is on screen.
223
+ pageChanged() {
224
+ this.broadcast({ type: 'page', page: this.screencast.pageInfo });
225
+ }
226
+
227
+ sendFrame(viewer, text) {
228
+ if (viewer.ws.readyState !== WebSocket.OPEN) return;
229
+ if (viewer.ws.bufferedAmount > MAX_BUFFERED_BYTES) {
230
+ viewer.behind = true;
231
+ this.scheduleCatchUp();
232
+ return;
233
+ }
234
+ viewer.behind = false;
235
+ viewer.ws.send(text);
236
+ }
237
+
238
+ scheduleCatchUp() {
239
+ if (this.catchUp) return;
240
+
241
+ this.catchUp = setInterval(() => {
242
+ const behind = [...this.viewers.values()].filter((viewer) => viewer.behind);
243
+ if (behind.length === 0 || !this.screencast.lastFrame) {
244
+ clearInterval(this.catchUp);
245
+ this.catchUp = null;
246
+ return;
247
+ }
248
+ const text = JSON.stringify(frameMessage(this.screencast.lastFrame));
249
+ for (const viewer of behind) this.sendFrame(viewer, text);
250
+ }, CATCH_UP_MS);
251
+ this.catchUp.unref?.();
252
+ }
253
+
254
+ send(viewer, message) {
255
+ if (viewer.ws.readyState === WebSocket.OPEN) viewer.ws.send(JSON.stringify(message));
256
+ }
257
+
258
+ broadcast(message) {
259
+ for (const viewer of this.viewers.values()) this.send(viewer, message);
260
+ }
261
+
262
+ // Ends every connection and releases control, refusing the agent calls
263
+ // still waiting for it: the browser is stopping.
264
+ close() {
265
+ if (this.closed) return;
266
+ this.closed = true;
267
+ clearInterval(this.catchUp);
268
+ this.catchUp = null;
269
+
270
+ this.lock.close();
271
+ for (const ws of this.connections) {
272
+ ws.close(CLOSE_CODES.stopping, 'The browser stopped');
273
+ setTimeout(() => ws.terminate(), TERMINATE_AFTER_MS).unref?.();
274
+ }
275
+ this.wss.close();
276
+ }
277
+ }
278
+
279
+ function frameMessage({ data, metadata }) {
280
+ return { type: 'frame', data, width: metadata.deviceWidth, height: metadata.deviceHeight };
281
+ }
@@ -0,0 +1,126 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+
3
+ // The dashboard derives the same key from the same token, so a ticket made
4
+ // for one browser start is useless against any other.
5
+ export const TICKET_CONTEXT = 'activeagents/browser-live-ticket/v1';
6
+ export const MODES = ['view', 'control'];
7
+ // The longest a ticket may live, whatever it claims, and how far ahead of
8
+ // this machine's clock the dashboard's may be.
9
+ export const MAX_TICKET_SECONDS = 60;
10
+ const CLOCK_SKEW_SECONDS = 5;
11
+ const MAX_TICKET_CHARS = 2048;
12
+ const MAX_NAME_CHARS = 80;
13
+ const TICKET = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
14
+ const TICKET_ID = /^[A-Za-z0-9_-]{16,64}$/;
15
+
16
+ /**
17
+ * Returns the key tickets are signed with: an HMAC-SHA256 of TICKET_CONTEXT
18
+ * under the browser's bearer token.
19
+ *
20
+ * @param {string} token
21
+ * @returns {Buffer}
22
+ */
23
+ export function ticketKey(token) {
24
+ return createHmac('sha256', token).update(TICKET_CONTEXT).digest();
25
+ }
26
+
27
+ function sign(key, payload) {
28
+ return createHmac('sha256', key).update(payload).digest();
29
+ }
30
+
31
+ /**
32
+ * Returns a ticket for `claims`, signed with `key`, in the form the
33
+ * dashboard issues: base64url(JSON claims), a dot, base64url(HMAC-SHA256 of
34
+ * that first part). Used by tests; the dashboard issues the real ones.
35
+ *
36
+ * @param {Buffer} key see ticketKey
37
+ * @param {object} claims
38
+ * @returns {string}
39
+ */
40
+ export function signTicket(key, claims) {
41
+ const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
42
+ return `${payload}.${sign(key, payload).toString('base64url')}`;
43
+ }
44
+
45
+ function nullableString(value, limit) {
46
+ if (value === null || value === undefined) return null;
47
+ return typeof value === 'string' ? value.slice(0, limit) : undefined;
48
+ }
49
+
50
+ /**
51
+ * Used to check the tickets a viewer presents. A ticket is accepted once:
52
+ * signed with this browser's key, naming this sandbox session and a mode,
53
+ * issued for at most MAX_TICKET_SECONDS, unexpired, and not seen before.
54
+ *
55
+ * Claims:
56
+ * v 1
57
+ * sid the sandbox session
58
+ * sub who it was issued to, or null when the dashboard has no signed-in user
59
+ * name their name to show other viewers, or null
60
+ * mode "view" (watch) or "control" (take over)
61
+ * iat when it was issued, in epoch seconds
62
+ * exp when it expires, in epoch seconds
63
+ * jti its unique id
64
+ */
65
+ export class TicketVerifier {
66
+ /**
67
+ * @param {object} options
68
+ * @param {Buffer} options.key see ticketKey
69
+ * @param {string} options.sessionId the sandbox session this browser belongs to
70
+ * @param {() => number} [options.now] epoch milliseconds
71
+ */
72
+ constructor({ key, sessionId, now = Date.now }) {
73
+ this.key = key;
74
+ this.sessionId = sessionId;
75
+ this.now = now;
76
+ this.used = new Map();
77
+ }
78
+
79
+ /**
80
+ * Checks `ticket` and, when it is valid, marks it used.
81
+ *
82
+ * @param {unknown} ticket
83
+ * @returns {{ claims: { sub: string|null, name: string|null, mode: string, exp: number } } | { refusal: string }}
84
+ */
85
+ verify(ticket) {
86
+ if (typeof ticket !== 'string' || ticket.length > MAX_TICKET_CHARS || !TICKET.test(ticket)) return { refusal: 'malformed' };
87
+
88
+ const [payload, signature] = ticket.split('.');
89
+ const given = Buffer.from(signature, 'base64url');
90
+ const expected = sign(this.key, payload);
91
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) return { refusal: 'bad signature' };
92
+
93
+ let claims;
94
+ try {
95
+ claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
96
+ } catch {
97
+ return { refusal: 'malformed' };
98
+ }
99
+ if (!claims || typeof claims !== 'object' || claims.v !== 1) return { refusal: 'malformed' };
100
+ if (claims.sid !== this.sessionId) return { refusal: 'another sandbox' };
101
+ if (!MODES.includes(claims.mode)) return { refusal: 'unknown mode' };
102
+
103
+ const { iat, exp, jti } = claims;
104
+ const now = this.now() / 1000;
105
+ if (!Number.isInteger(iat) || !Number.isInteger(exp) || exp <= iat || exp - iat > MAX_TICKET_SECONDS) return { refusal: 'bad lifetime' };
106
+ if (iat > now + CLOCK_SKEW_SECONDS) return { refusal: 'issued in the future' };
107
+ if (exp <= now) return { refusal: 'expired' };
108
+ if (typeof jti !== 'string' || !TICKET_ID.test(jti)) return { refusal: 'malformed' };
109
+
110
+ const sub = nullableString(claims.sub, 256);
111
+ const name = nullableString(claims.name, MAX_NAME_CHARS);
112
+ if (sub === undefined || name === undefined) return { refusal: 'malformed' };
113
+
114
+ this.forgetExpired(now);
115
+ if (this.used.has(jti)) return { refusal: 'already used' };
116
+ this.used.set(jti, exp);
117
+
118
+ return { claims: { sub, name, mode: claims.mode, exp } };
119
+ }
120
+
121
+ forgetExpired(now) {
122
+ for (const [jti, exp] of this.used) {
123
+ if (exp <= now) this.used.delete(jti);
124
+ }
125
+ }
126
+ }