openmausbot 0.1.65 → 0.1.67

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.
Files changed (54) hide show
  1. package/dist/assets/index-A3CBTxwl.css +1 -0
  2. package/dist/assets/{index-BZeH50da.js → index-BwpNqgCm.js} +1 -1
  3. package/dist/assets/index-CDuyoVPJ.js +278 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/browser-proxy.js +121 -0
  6. package/dist-server/computer-proxy.js +14 -3
  7. package/dist-server/container-mcp.js +43 -19
  8. package/dist-server/drivers/agents-proxy.js +31 -0
  9. package/dist-server/index.js +18985 -16059
  10. package/dist-server/local-computer-proxy.js +433 -0
  11. package/dist-server/local-computer.js +48 -5
  12. package/dist-server/mcp-server.js +38 -17
  13. package/dist-server/openmausbot.js +1666 -775
  14. package/dist-server/pair-cli.js +1666 -775
  15. package/dist-server/proxy-paths.js +2 -0
  16. package/dist-server/server/account-signin.js +103 -0
  17. package/dist-server/server/attachments.js +125 -13
  18. package/dist-server/server/browser-engine-release.js +10 -8
  19. package/dist-server/server/browser-engine.js +226 -26
  20. package/dist-server/server/browser-live.js +567 -0
  21. package/dist-server/server/browser-proxy.js +129 -0
  22. package/dist-server/server/browser-runtime.js +370 -0
  23. package/dist-server/server/caddy.js +203 -0
  24. package/dist-server/server/claude-accounts.js +103 -0
  25. package/dist-server/server/cli.js +163 -22
  26. package/dist-server/server/computer-proxy.js +11 -2
  27. package/dist-server/server/config.js +41 -19
  28. package/dist-server/server/control-client.js +7 -1
  29. package/dist-server/server/custom-domain.js +14 -0
  30. package/dist-server/server/drivers/agents-proxy.js +34 -0
  31. package/dist-server/server/drivers/claude-login-auth.js +334 -0
  32. package/dist-server/server/drivers/claude.js +85 -19
  33. package/dist-server/server/environment.js +1 -0
  34. package/dist-server/server/harness/bus.js +9 -4
  35. package/dist-server/server/harness/registry.js +15 -1
  36. package/dist-server/server/index.js +1120 -379
  37. package/dist-server/server/local-computer-proxy.js +28 -0
  38. package/dist-server/server/local-computer.js +17 -0
  39. package/dist-server/server/mcp-bridge.js +27 -11
  40. package/dist-server/server/peer-approval.js +19 -6
  41. package/dist-server/server/provider-auth-sessions.js +7 -0
  42. package/dist-server/server/proxy-paths.js +2 -0
  43. package/dist-server/server/sessions.js +42 -1
  44. package/dist-server/server/steer-queue.js +10 -8
  45. package/dist-server/server/store.js +234 -34
  46. package/dist-server/server/team-backup.js +1 -0
  47. package/dist-server/server/tunnel.js +27 -1
  48. package/dist-server/server/turn-resources.js +50 -0
  49. package/dist-server/server/workspace.js +53 -3
  50. package/dist-server/src/lib/composer-attachments.js +3 -3
  51. package/dist-server/vps-container-mcp.js +43 -19
  52. package/package.json +1 -1
  53. package/dist/assets/index-BOrNJcfG.js +0 -273
  54. package/dist/assets/index-Dwzk199I.css +0 -1
@@ -0,0 +1,567 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { promisify } from "node:util";
4
+ import { browserRuntimeEnv } from "./browser-runtime.js";
5
+ import { closeBrowserSession } from "./browser-engine.js";
6
+ const execute = promisify(execFile);
7
+ const MAX_FRAME = 3 * 1024 * 1024;
8
+ const MAX_BUFFER = 4 * 1024 * 1024;
9
+ const HEARTBEAT_MS = 10_000;
10
+ export class BrowserLiveError extends Error {
11
+ status;
12
+ constructor(message, status = 400) { super(message); this.name = "BrowserLiveError"; this.status = status; }
13
+ }
14
+ function object(value) {
15
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
16
+ }
17
+ function number(value, min, max) {
18
+ return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
19
+ }
20
+ function integer(value, min, max) {
21
+ return number(value, min, max) && Number.isSafeInteger(value);
22
+ }
23
+ function text(value, max) { return typeof value === "string" && value.length <= max; }
24
+ function displayUrl(value) {
25
+ if (!text(value, 8192))
26
+ return "";
27
+ try {
28
+ const url = new URL(value);
29
+ if (!['http:', 'https:', 'about:'].includes(url.protocol))
30
+ return "";
31
+ url.username = "";
32
+ url.password = "";
33
+ return url.href;
34
+ }
35
+ catch {
36
+ return "";
37
+ }
38
+ }
39
+ /** Project only the viewer protocol. Never relay engine commands, results or console output. */
40
+ export function normalizeBrowserLiveMessage(value) {
41
+ const message = object(value);
42
+ if (!message)
43
+ return null;
44
+ if (message.type === "error")
45
+ return { type: "error", message: "The browser stream was interrupted. Reopen the browser panel to reconnect." };
46
+ if (message.type === "url")
47
+ return { type: "url", url: displayUrl(message.url) };
48
+ if (message.type === "tabs" && Array.isArray(message.tabs) && message.tabs.length <= 100) {
49
+ return { type: "tabs", tabs: message.tabs.flatMap((value) => {
50
+ const tab = object(value);
51
+ if (!tab || typeof tab.tabId !== "string" || !/^t[1-9]\d{0,8}$/.test(tab.tabId))
52
+ return [];
53
+ return [{ tabId: tab.tabId, title: text(tab.title, 512) ? tab.title : "", url: displayUrl(tab.url), active: tab.active === true }];
54
+ }) };
55
+ }
56
+ if (message.type === "status" && typeof message.connected === "boolean") {
57
+ return { type: "status", connected: message.connected, screencasting: message.screencasting === true,
58
+ viewportWidth: integer(message.viewportWidth, 1, 8192) ? message.viewportWidth : 1280,
59
+ viewportHeight: integer(message.viewportHeight, 1, 8192) ? message.viewportHeight : 720 };
60
+ }
61
+ if (message.type !== "frame" || !integer(message.seq, 1, Number.MAX_SAFE_INTEGER)
62
+ || !text(message.data, MAX_FRAME) || !/^(?:\/9j\/|iVBORw0KGgo)/.test(message.data)
63
+ || !/^[A-Za-z0-9+/]*={0,2}$/.test(message.data))
64
+ return null;
65
+ const meta = object(message.metadata);
66
+ if (!meta || !integer(meta.deviceWidth, 1, 8192) || !integer(meta.deviceHeight, 1, 8192)
67
+ || meta.deviceWidth * meta.deviceHeight > 16_777_216)
68
+ return null;
69
+ return { type: "frame", seq: message.seq, data: message.data, format: message.data.startsWith("/9j/") ? "jpeg" : "png",
70
+ metadata: { deviceWidth: meta.deviceWidth, deviceHeight: meta.deviceHeight,
71
+ offsetTop: number(meta.offsetTop, 0, 8192) ? meta.offsetTop : 0,
72
+ pageScaleFactor: number(meta.pageScaleFactor, 0.01, 100) ? meta.pageScaleFactor : 1,
73
+ scrollOffsetX: number(meta.scrollOffsetX, -1e8, 1e8) ? meta.scrollOffsetX : 0,
74
+ scrollOffsetY: number(meta.scrollOffsetY, -1e8, 1e8) ? meta.scrollOffsetY : 0,
75
+ timestamp: number(meta.timestamp, 0, Number.MAX_SAFE_INTEGER) ? meta.timestamp : 0 } };
76
+ }
77
+ export function browserStreamPort(value) {
78
+ const data = object(value);
79
+ if (!data || data.enabled !== true || !integer(data.port, 1, 65535)) {
80
+ throw new BrowserLiveError("The browser did not provide a valid local stream. Update or reinstall the browser engine.", 503);
81
+ }
82
+ return data.port;
83
+ }
84
+ /** HTTP bodies can select a fixed action, never a CLI command, flag, port or CDP method. */
85
+ export function parseBrowserLiveAction(value) {
86
+ const body = object(value);
87
+ const invalid = () => new BrowserLiveError("Invalid browser action.");
88
+ if (!body)
89
+ throw invalid();
90
+ if (body.type === "take" || body.type === "release" || body.type === "restart")
91
+ return { type: body.type };
92
+ if (body.type === "ack" && integer(body.seq, 1, Number.MAX_SAFE_INTEGER))
93
+ return { type: "ack", seq: body.seq };
94
+ if (body.type === "back" || body.type === "forward" || body.type === "reload")
95
+ return { type: "command", args: [body.type] };
96
+ if (body.type === "navigate" || body.type === "tab-new") {
97
+ if (body.type === "tab-new" && body.url === undefined)
98
+ return { type: "command", args: ["tab", "new"] };
99
+ // eslint-disable-next-line no-control-regex -- Reject control characters before URL parsing normalizes them.
100
+ if (!text(body.url, 8192) || !body.url || /[\u0000-\u0020\u007f\\]/u.test(body.url))
101
+ throw invalid();
102
+ let url;
103
+ try {
104
+ url = new URL(body.url);
105
+ }
106
+ catch {
107
+ throw invalid();
108
+ }
109
+ if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password)
110
+ throw invalid();
111
+ return { type: "command", args: body.type === "navigate" ? ["open", url.href] : ["tab", "new", url.href] };
112
+ }
113
+ if (body.type === "tab-select" || body.type === "tab-close") {
114
+ if (typeof body.tabId !== "string" || !/^t[1-9]\d{0,8}$/.test(body.tabId))
115
+ throw invalid();
116
+ return { type: "command", args: body.type === "tab-select" ? ["tab", body.tabId] : ["tab", "close", body.tabId] };
117
+ }
118
+ if (body.type === "input_mouse") {
119
+ if (!["mouseMoved", "mousePressed", "mouseReleased", "mouseWheel"].includes(String(body.eventType))
120
+ || !number(body.x, 0, 8192) || !number(body.y, 0, 8192))
121
+ throw invalid();
122
+ const button = body.button ?? "none";
123
+ const clickCount = body.clickCount ?? 0, modifiers = body.modifiers ?? 0;
124
+ const deltaX = body.deltaX ?? 0, deltaY = body.deltaY ?? 0;
125
+ if (!["none", "left", "middle", "right"].includes(String(button)) || !integer(clickCount, 0, 3)
126
+ || !integer(modifiers, 0, 15) || !number(deltaX, -10000, 10000) || !number(deltaY, -10000, 10000))
127
+ throw invalid();
128
+ return { type: "input", message: { type: "input_mouse", eventType: body.eventType, x: body.x, y: body.y, button, clickCount, modifiers, deltaX, deltaY } };
129
+ }
130
+ if (body.type === "input_keyboard") {
131
+ if (body.eventType === "char") {
132
+ if (!text(body.text, 4096) || !body.text)
133
+ throw invalid();
134
+ return { type: "input", message: { type: "input_keyboard", eventType: "char", text: body.text } };
135
+ }
136
+ if (!["keyDown", "keyUp"].includes(String(body.eventType)) || !text(body.key, 64) || !body.key)
137
+ throw invalid();
138
+ const modifiers = body.modifiers ?? 0, keyCode = body.windowsVirtualKeyCode ?? 0;
139
+ if (!integer(modifiers, 0, 15) || !integer(keyCode, 0, 255)
140
+ || (body.code !== undefined && !text(body.code, 64)) || (body.text !== undefined && !text(body.text, 4096)))
141
+ throw invalid();
142
+ return { type: "input", message: { type: "input_keyboard", eventType: body.eventType, key: body.key,
143
+ modifiers, windowsVirtualKeyCode: keyCode, ...(body.code === undefined ? {} : { code: body.code }), ...(body.text === undefined ? {} : { text: body.text }) } };
144
+ }
145
+ throw invalid();
146
+ }
147
+ export class BrowserLive {
148
+ runtime;
149
+ viewers = new Map();
150
+ constructor({ runtime }) { this.runtime = runtime; }
151
+ current(viewer) {
152
+ try {
153
+ return !viewer.closed && !viewer.res.destroyed && !viewer.res.writableEnded && viewer.isCurrent();
154
+ }
155
+ catch {
156
+ return false;
157
+ }
158
+ }
159
+ send(viewer, message) {
160
+ if (!this.current(viewer)) {
161
+ this.close(viewer);
162
+ return;
163
+ }
164
+ if (!viewer.res.headersSent)
165
+ return; // A peer can change control while this viewer is still starting.
166
+ if (viewer.res.writableLength > MAX_BUFFER) {
167
+ this.close(viewer);
168
+ return;
169
+ }
170
+ if (viewer.blocked)
171
+ return;
172
+ const { type, ...data } = message;
173
+ let written;
174
+ try {
175
+ written = viewer.res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
176
+ }
177
+ catch {
178
+ this.close(viewer);
179
+ return;
180
+ }
181
+ if (!written) {
182
+ viewer.blocked = true;
183
+ viewer.drainTimer = setTimeout(() => this.close(viewer), HEARTBEAT_MS);
184
+ viewer.drainTimer.unref();
185
+ viewer.res.once("drain", () => {
186
+ viewer.blocked = false;
187
+ clearTimeout(viewer.drainTimer);
188
+ this.control(viewer.session);
189
+ const pending = viewer.pendingFrame;
190
+ viewer.pendingFrame = undefined;
191
+ if (pending)
192
+ this.frame(viewer, pending);
193
+ });
194
+ }
195
+ }
196
+ control(session) {
197
+ for (const viewer of this.viewers.values()) {
198
+ if (viewer.session !== session)
199
+ continue;
200
+ const held = Boolean(this.runtime.heldBy(session));
201
+ if (held && this.runtime.heldBy(session) !== viewer.id && viewer.frameSeq) {
202
+ try {
203
+ this.sendSocket(viewer, { type: "ack", seq: viewer.frameSeq });
204
+ }
205
+ catch {
206
+ this.close(viewer);
207
+ continue;
208
+ }
209
+ viewer.frameSeq = undefined;
210
+ viewer.frameAt = undefined;
211
+ }
212
+ if (held && this.runtime.heldBy(session) !== viewer.id && viewer.pendingFrame) {
213
+ const pending = viewer.pendingFrame;
214
+ viewer.pendingFrame = undefined;
215
+ this.frame(viewer, pending);
216
+ }
217
+ this.send(viewer, { type: "control", held, owned: this.runtime.heldBy(session) === viewer.id, controlling: this.runtime.canControl(session, viewer.id) });
218
+ if (!held && viewer.hiddenFrame) {
219
+ const frame = viewer.hiddenFrame;
220
+ viewer.hiddenFrame = undefined;
221
+ this.frame(viewer, frame);
222
+ }
223
+ }
224
+ }
225
+ frame(viewer, frame) {
226
+ if (!this.current(viewer)) {
227
+ this.close(viewer);
228
+ return;
229
+ }
230
+ const holder = this.runtime.heldBy(viewer.session);
231
+ if (holder && holder !== viewer.id) {
232
+ viewer.hiddenFrame = frame;
233
+ try {
234
+ this.sendSocket(viewer, { type: "ack", seq: frame.seq });
235
+ }
236
+ catch {
237
+ this.close(viewer);
238
+ }
239
+ return;
240
+ }
241
+ // ACKs can arrive before the SSE transport drains. Keep one newest frame
242
+ // instead of dropping it and then waiting for an ACK the viewer cannot send.
243
+ if (viewer.blocked) {
244
+ viewer.pendingFrame = frame;
245
+ return;
246
+ }
247
+ viewer.pendingFrame = undefined;
248
+ viewer.frameSeq = frame.seq;
249
+ viewer.frameAt = Date.now();
250
+ this.send(viewer, frame);
251
+ }
252
+ sendSocket(viewer, message) {
253
+ if (viewer.socket?.readyState !== WebSocket.OPEN || viewer.socket.bufferedAmount > 64 * 1024) {
254
+ throw new BrowserLiveError("The browser connection closed. Reopen the browser panel.", 409);
255
+ }
256
+ viewer.socket.send(JSON.stringify(message));
257
+ }
258
+ close(viewer, endResponse = true) {
259
+ if (viewer.closed)
260
+ return;
261
+ viewer.closed = true;
262
+ this.viewers.delete(viewer.id);
263
+ clearTimeout(viewer.heartbeat);
264
+ clearTimeout(viewer.drainTimer);
265
+ viewer.socket?.close();
266
+ viewer.hiddenFrame = undefined;
267
+ viewer.pendingFrame = undefined;
268
+ if (!viewer.restarting) {
269
+ this.abandonPressedInput(viewer);
270
+ this.runtime.release(viewer.session, viewer.id);
271
+ }
272
+ if (endResponse && !viewer.res.writableEnded && !viewer.res.destroyed) {
273
+ if (viewer.blocked || viewer.res.writableLength > MAX_BUFFER)
274
+ viewer.res.destroy();
275
+ else
276
+ viewer.res.end();
277
+ }
278
+ this.control(viewer.session);
279
+ }
280
+ abandonPressedInput(viewer) {
281
+ // A disconnect may lose the matching key-up/mouse-up. Do not let an
282
+ // agent inherit held input; an explicit restart is the recovery barrier.
283
+ if (viewer.pressedKeys.size || viewer.pressedButtons.size)
284
+ this.runtime.abandonHumanInput(viewer.session, viewer.id);
285
+ }
286
+ async command(viewer, args) {
287
+ if (!this.current(viewer))
288
+ throw new BrowserLiveError("This browser view is no longer available.", 409);
289
+ const env = browserRuntimeEnv({ ...viewer.spec.env, AGENT_BROWSER_SESSION: viewer.session });
290
+ try {
291
+ const { stdout } = await execute(viewer.spec.command, [...args, "--json", "--no-webmcp"], {
292
+ env, timeout: 30_000, maxBuffer: 1024 * 1024, encoding: "utf8", windowsHide: true,
293
+ });
294
+ if (!this.current(viewer))
295
+ throw new Error("stale viewer");
296
+ const result = object(JSON.parse(stdout));
297
+ const data = object(result?.data);
298
+ if (result?.success !== true || !data)
299
+ throw new Error("browser command failed");
300
+ return data;
301
+ }
302
+ catch {
303
+ throw new BrowserLiveError("The browser could not complete this action. Check that the browser engine is installed, then reconnect.", 503);
304
+ }
305
+ }
306
+ async input(viewer, message) {
307
+ if (!viewer.port)
308
+ throw new BrowserLiveError("The browser stream is not connected.", 409);
309
+ let command;
310
+ const { type, eventType, ...fields } = message;
311
+ const shortcut = Number(fields.modifiers) > 0 && !["Alt", "Control", "Meta", "Shift"].includes(String(fields.key))
312
+ && !(fields.modifiers === 8 && text(fields.key, 64) && [...fields.key].length === 1);
313
+ if (type === "input_keyboard" && eventType === "keyUp" && shortcut)
314
+ return; // press already releases the chord.
315
+ if (type === "input_mouse")
316
+ command = { action: "input_mouse", type: eventType, ...fields };
317
+ else if (eventType === "char")
318
+ command = { action: "keyboard", subaction: "insertText", text: fields.text };
319
+ else if (eventType === "keyDown" && shortcut) {
320
+ // The native input_keyboard handler omits CDP modifiers. Its press
321
+ // action resolves chords and awaits the complete key-down/up pair.
322
+ const modifiers = fields.modifiers;
323
+ const chord = [[1, "Alt"], [2, "Control"], [4, "Meta"], [8, "Shift"]];
324
+ command = { action: "press", key: [...chord.filter(([bit]) => modifiers & bit).map(([, key]) => key), fields.key].join("+") };
325
+ }
326
+ else
327
+ command = { action: "input_keyboard", type: eventType, key: fields.key,
328
+ ...(fields.code === undefined ? {} : { code: fields.code }), ...(fields.text === undefined ? {} : { text: fields.text }) };
329
+ const key = String(fields.code || fields.key);
330
+ if (command.action === "input_keyboard" && eventType === "keyDown") {
331
+ if (viewer.pressedKeys.size >= 64 && !viewer.pressedKeys.has(key))
332
+ throw new BrowserLiveError("Too many keys are held. Restart the browser before continuing.", 409);
333
+ viewer.pressedKeys.add(key);
334
+ }
335
+ if (type === "input_mouse" && eventType === "mousePressed")
336
+ viewer.pressedButtons.add(String(fields.button));
337
+ const origin = `http://127.0.0.1:${viewer.port}`;
338
+ try {
339
+ // The native command relay awaits the daemon's CDP reply. A WS send is
340
+ // only an enqueue, so it cannot serve as the human hand-back barrier.
341
+ const response = await fetch(`${origin}/api/command`, { method: "POST", redirect: "error",
342
+ headers: { "Content-Type": "application/json", Origin: origin }, body: JSON.stringify(command), signal: AbortSignal.timeout(30_000) });
343
+ if (!response.ok || !response.body) {
344
+ await response.body?.cancel();
345
+ throw new Error("input rejected");
346
+ }
347
+ const chunks = [];
348
+ let size = 0;
349
+ for await (const chunk of response.body) {
350
+ size += chunk.length;
351
+ if (size > 65536)
352
+ throw new Error("input response too large");
353
+ chunks.push(chunk);
354
+ }
355
+ if (object(JSON.parse(Buffer.concat(chunks).toString("utf8")))?.success !== true)
356
+ throw new Error("input failed");
357
+ if (command.action === "input_keyboard" && eventType === "keyUp")
358
+ viewer.pressedKeys.delete(key);
359
+ if (type === "input_mouse" && eventType === "mouseReleased")
360
+ viewer.pressedButtons.delete(String(fields.button));
361
+ }
362
+ catch {
363
+ throw new BrowserLiveError("The browser could not confirm this input. Restart the browser before continuing.", 503);
364
+ }
365
+ }
366
+ async open(options) {
367
+ if (this.viewers.size >= 8 || [...this.viewers.values()].filter((v) => v.session === options.session).length >= 2) {
368
+ throw new BrowserLiveError("Too many browser views are open. Close another browser panel first.", 429);
369
+ }
370
+ const viewer = { ...options, id: randomUUID(), closed: false, blocked: false, pendingActions: 0, restarting: false, pressedKeys: new Set(), pressedButtons: new Set() };
371
+ if (!this.current(viewer))
372
+ throw new BrowserLiveError("This browser view is no longer available.", 409);
373
+ this.viewers.set(viewer.id, viewer);
374
+ options.res.once("close", () => this.close(viewer));
375
+ options.res.once("error", () => this.close(viewer));
376
+ try {
377
+ let status = await this.command(viewer, ["stream", "status"]);
378
+ if (status.enabled !== true) {
379
+ try {
380
+ status = await this.command(viewer, ["stream", "enable"]);
381
+ }
382
+ catch {
383
+ status = await this.command(viewer, ["stream", "status"]);
384
+ } // Another view may have enabled the same session.
385
+ }
386
+ const port = browserStreamPort(status);
387
+ viewer.port = port;
388
+ // `open` without a URL is an idempotent launch, never navigation away from the bot's page.
389
+ if (status.connected !== true)
390
+ await this.command(viewer, ["open"]);
391
+ const socket = new WebSocket(`ws://127.0.0.1:${port}/?pacing=ack&maxFps=15`);
392
+ viewer.socket = socket;
393
+ options.res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", "X-Accel-Buffering": "no", Connection: "keep-alive" });
394
+ options.res.flushHeaders();
395
+ this.send(viewer, { type: "ready", viewerId: viewer.id });
396
+ this.control(viewer.session);
397
+ socket.addEventListener("message", (event) => {
398
+ if (!this.current(viewer)) {
399
+ this.close(viewer);
400
+ return;
401
+ }
402
+ if (typeof event.data !== "string" || event.data.length > MAX_BUFFER) {
403
+ this.close(viewer);
404
+ return;
405
+ }
406
+ let message;
407
+ let raw;
408
+ try {
409
+ raw = object(JSON.parse(event.data));
410
+ message = normalizeBrowserLiveMessage(raw);
411
+ }
412
+ catch {
413
+ this.close(viewer);
414
+ return;
415
+ }
416
+ if (!message) {
417
+ if (raw?.type === "frame")
418
+ this.close(viewer);
419
+ return;
420
+ }
421
+ const heldBy = this.runtime.heldBy(viewer.session);
422
+ if (heldBy && heldBy !== viewer.id && ["tabs", "url"].includes(String(message.type)))
423
+ return;
424
+ if (message.type === "frame")
425
+ this.frame(viewer, message);
426
+ else
427
+ this.send(viewer, message);
428
+ });
429
+ socket.addEventListener("error", () => { if (!viewer.restarting) {
430
+ this.send(viewer, { type: "error", message: "The browser stream disconnected. Reopen the browser panel." });
431
+ this.close(viewer);
432
+ } });
433
+ socket.addEventListener("close", () => { if (!viewer.restarting)
434
+ this.close(viewer); });
435
+ // Install stream handlers before awaiting the upgrade: upstream can send
436
+ // its cached status, tabs and opening frame immediately after opening.
437
+ await new Promise((resolve, reject) => {
438
+ const timer = setTimeout(() => reject(new BrowserLiveError("The browser stream did not connect. Reopen the browser panel.", 503)), 10_000);
439
+ const failed = () => { clearTimeout(timer); reject(new BrowserLiveError("The browser stream did not connect. Reopen the browser panel.", 503)); };
440
+ socket.addEventListener("error", failed, { once: true });
441
+ socket.addEventListener("close", failed, { once: true });
442
+ socket.addEventListener("open", () => { clearTimeout(timer); socket.removeEventListener("error", failed); socket.removeEventListener("close", failed); resolve(); }, { once: true });
443
+ });
444
+ if (!this.current(viewer)) {
445
+ this.close(viewer);
446
+ return;
447
+ }
448
+ viewer.heartbeat = setInterval(() => {
449
+ if (!this.current(viewer) || (viewer.frameAt && Date.now() - viewer.frameAt > 2 * HEARTBEAT_MS)) {
450
+ this.close(viewer);
451
+ return;
452
+ }
453
+ this.control(viewer.session);
454
+ this.send(viewer, { type: "heartbeat" });
455
+ }, HEARTBEAT_MS);
456
+ viewer.heartbeat.unref();
457
+ }
458
+ catch (error) {
459
+ if (options.res.headersSent) {
460
+ this.send(viewer, { type: "error", message: "The browser stream could not start. Reopen the browser panel." });
461
+ this.close(viewer);
462
+ return;
463
+ }
464
+ this.close(viewer, options.res.headersSent);
465
+ throw error instanceof BrowserLiveError ? error : new BrowserLiveError("The browser stream could not start. Reopen the browser panel.", 503);
466
+ }
467
+ }
468
+ async action({ viewerId, botId, owner, body }) {
469
+ const viewer = this.viewers.get(viewerId);
470
+ if (!viewer || viewer.botId !== botId || viewer.owner !== owner || !this.current(viewer)) {
471
+ if (viewer && viewer.botId === botId && viewer.owner === owner)
472
+ this.close(viewer);
473
+ throw new BrowserLiveError("This browser view is no longer available. Reopen the browser panel.", 404);
474
+ }
475
+ const action = parseBrowserLiveAction(body);
476
+ if (action.type === "restart") {
477
+ if (viewer.restarting)
478
+ throw new BrowserLiveError("The browser is already restarting.", 409);
479
+ viewer.restarting = true;
480
+ try {
481
+ const restarting = this.runtime.restart(viewer.session, viewer.id, async () => {
482
+ if (!this.current(viewer))
483
+ throw new Error("stale viewer");
484
+ const env = browserRuntimeEnv({ ...viewer.spec.env, AGENT_BROWSER_SESSION: viewer.session });
485
+ if (!await closeBrowserSession(viewer.spec.command, env))
486
+ throw new Error("browser close failed");
487
+ });
488
+ this.control(viewer.session);
489
+ await restarting;
490
+ this.closeForSession(viewer.session);
491
+ return { ok: true };
492
+ }
493
+ catch {
494
+ throw new BrowserLiveError("The browser could not restart. Wait for active work to finish and try again.", 409);
495
+ }
496
+ finally {
497
+ viewer.restarting = false;
498
+ if (viewer.closed)
499
+ this.runtime.release(viewer.session, viewer.id);
500
+ this.control(viewer.session);
501
+ }
502
+ }
503
+ if (action.type === "ack") {
504
+ if (viewer.frameSeq === action.seq) {
505
+ this.sendSocket(viewer, { type: "ack", seq: action.seq });
506
+ viewer.frameSeq = undefined;
507
+ viewer.frameAt = undefined;
508
+ }
509
+ return { ok: true };
510
+ }
511
+ if (action.type === "release") {
512
+ this.abandonPressedInput(viewer);
513
+ this.runtime.release(viewer.session, viewer.id);
514
+ this.control(viewer.session);
515
+ return { ok: true };
516
+ }
517
+ if (action.type === "take") {
518
+ try {
519
+ const taking = this.runtime.take(viewer.session, viewer.id);
520
+ this.control(viewer.session);
521
+ await taking;
522
+ if (!this.current(viewer)) {
523
+ this.close(viewer);
524
+ throw new BrowserLiveError("This browser view closed.", 409);
525
+ }
526
+ return { ok: true };
527
+ }
528
+ catch {
529
+ throw new BrowserLiveError("Another browser view or bot action is using this browser. Try again shortly.", 409);
530
+ }
531
+ finally {
532
+ this.control(viewer.session);
533
+ }
534
+ }
535
+ if (!this.runtime.canControl(viewer.session, viewer.id))
536
+ throw new BrowserLiveError("Take control of this browser before interacting.", 409);
537
+ if (viewer.pendingActions >= 32)
538
+ throw new BrowserLiveError("Too many browser actions are pending. Try again shortly.", 429);
539
+ viewer.pendingActions += 1;
540
+ try {
541
+ await this.runtime.withHumanAction(viewer.session, viewer.id, async () => {
542
+ if (!this.current(viewer))
543
+ throw new BrowserLiveError("This browser view closed.", 409);
544
+ if (action.type === "input")
545
+ await this.input(viewer, action.message);
546
+ else if (action.type === "command")
547
+ await this.command(viewer, action.args);
548
+ });
549
+ return { ok: true };
550
+ }
551
+ catch (error) {
552
+ throw error instanceof BrowserLiveError ? error : new BrowserLiveError("Browser control changed. Take control again to continue.", 409);
553
+ }
554
+ finally {
555
+ viewer.pendingActions -= 1;
556
+ this.control(viewer.session);
557
+ }
558
+ }
559
+ closeForSession(session) { for (const viewer of this.viewers.values())
560
+ if (viewer.session === session)
561
+ this.close(viewer); }
562
+ closeForBot(botId) { for (const viewer of this.viewers.values())
563
+ if (viewer.botId === botId)
564
+ this.close(viewer); }
565
+ closeAll() { for (const viewer of this.viewers.values())
566
+ this.close(viewer); }
567
+ }