im_wower
·
2026-04-02
firefox-ws.ts
1import { createHash, randomUUID } from "node:crypto";
2import { appendFileSync } from "node:fs";
3import type { IncomingMessage } from "node:http";
4import { join } from "node:path";
5import type { Socket } from "node:net";
6import type { ArtifactStore } from "../../../packages/artifact-db/dist/index.js";
7import type { ControlPlaneRepository } from "../../../packages/db/dist/index.js";
8
9import { BaaBrowserDeliveryBridge } from "./artifacts/upload-session.js";
10import type { BaaDeliveryRouteSnapshot } from "./artifacts/types.js";
11import {
12 FirefoxBridgeService,
13 FirefoxCommandBroker,
14 type FirefoxBridgeRegisteredClient
15} from "./firefox-bridge.js";
16import type {
17 BrowserBridgeDeliveryAckSnapshot,
18 BrowserBridgeActionResultItemSnapshot,
19 BrowserBridgeActionResultSnapshot,
20 BrowserBridgeActionResultSummarySnapshot,
21 BrowserBridgeActionResultTargetSnapshot,
22 BrowserBridgeConversationAutomationSnapshot,
23 BrowserBridgeClientSnapshot,
24 BrowserBridgeEndpointMetadataSnapshot,
25 BrowserBridgeFinalMessageSnapshot,
26 BrowserBridgeLoginStatus,
27 BrowserBridgeShellRuntimeSnapshot,
28 BrowserBridgeStateSnapshot
29} from "./browser-types.js";
30import type { BaaLiveInstructionIngest } from "./instructions/ingest.js";
31import { extractBaaInstructionBlocks } from "./instructions/extract.js";
32import { buildSystemStateData, setAutomationMode } from "./local-api.js";
33import type { ConductorRuntimeSnapshot } from "./index.js";
34import { recordAssistantMessageAutomationSignal } from "./renewal/automation.js";
35import {
36 observeRenewalConversation,
37 type ObserveRenewalConversationResult
38} from "./renewal/conversations.js";
39
40const FIREFOX_WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
41const BROWSER_WS_PROTOCOL = "baa.browser.local";
42const FIREFOX_WS_PROTOCOL = "baa.firefox.local";
43const BROWSER_WS_PROTOCOL_VERSION = 1;
44const FIREFOX_WS_STATE_POLL_INTERVAL_MS = 2_000;
45const FIREFOX_WS_LOGIN_STATE_STALE_AFTER_MS = 45_000;
46const FIREFOX_WS_LOGIN_STATE_LOST_AFTER_MS = 120_000;
47const MAX_FRAME_PAYLOAD_BYTES = 1024 * 1024;
48const CLIENT_REPLACED_CLOSE_CODE = 4001;
49const INVALID_MESSAGE_CLOSE_CODE = 4002;
50const NORMAL_CLOSE_CODE = 1000;
51const UNSUPPORTED_DATA_CLOSE_CODE = 1003;
52const MESSAGE_TOO_LARGE_CLOSE_CODE = 1009;
53
54export const BROWSER_WS_PATH = "/ws/browser";
55export const FIREFOX_WS_PATH = "/ws/firefox";
56
57type IntervalHandle = ReturnType<typeof globalThis.setInterval>;
58type FirefoxWsAction = "pause" | "resume" | "drain";
59
60interface FirefoxWebSocketServerOptions {
61 artifactStore?: ArtifactStore | null;
62 artifactInlineThreshold?: number | null;
63 artifactSummaryLength?: number | null;
64 baseUrlLoader: () => string;
65 ingestLogDir?: string | null;
66 instructionIngest?: BaaLiveInstructionIngest | null;
67 now?: () => number;
68 pluginDiagnosticLogDir?: string | null;
69 repository: ControlPlaneRepository;
70 snapshotLoader: () => ConductorRuntimeSnapshot;
71}
72
73interface FirefoxBrowserEndpointMetadataSummary {
74 firstSeenAt: number | null;
75 lastSeenAt: number | null;
76 method: string | null;
77 path: string;
78}
79
80interface FirefoxBrowserCredentialSummary {
81 account: string | null;
82 accountCapturedAt: number | null;
83 accountLastSeenAt: number | null;
84 capturedAt: number;
85 credentialFingerprint: string | null;
86 freshness: BrowserBridgeLoginStatus | null;
87 headerCount: number;
88 lastSeenAt: number | null;
89}
90
91interface FirefoxBrowserHookSummary {
92 account: string | null;
93 credentialFingerprint: string | null;
94 endpointMetadata: FirefoxBrowserEndpointMetadataSummary[];
95 endpoints: string[];
96 lastVerifiedAt: number | null;
97 updatedAt: number;
98}
99
100interface FirefoxBrowserSession {
101 clientId: string | null;
102 connectedAt: number;
103 credentials: Map<string, FirefoxBrowserCredentialSummary>;
104 finalMessages: BrowserBridgeFinalMessageSnapshot[];
105 id: string;
106 lastActionResult: BrowserBridgeActionResultSnapshot | null;
107 lastMessageAt: number;
108 nodeCategory: string | null;
109 nodePlatform: string | null;
110 nodeType: string | null;
111 requestHooks: Map<string, FirefoxBrowserHookSummary>;
112 shellRuntime: Map<string, BrowserBridgeShellRuntimeSnapshot>;
113}
114
115function normalizeBrowserLoginStatus(value: unknown): BrowserBridgeLoginStatus | null {
116 if (typeof value !== "string") {
117 return null;
118 }
119
120 switch (value.trim().toLowerCase()) {
121 case "fresh":
122 return "fresh";
123 case "stale":
124 return "stale";
125 case "lost":
126 return "lost";
127 default:
128 return null;
129 }
130}
131
132function normalizeDiagnosticLogLevel(value: unknown): "debug" | "error" | "info" | "warn" | null {
133 if (typeof value !== "string") {
134 return null;
135 }
136
137 switch (value.trim().toLowerCase()) {
138 case "debug":
139 return "debug";
140 case "error":
141 return "error";
142 case "info":
143 return "info";
144 case "warn":
145 return "warn";
146 default:
147 return null;
148 }
149}
150
151function asRecord(value: unknown): Record<string, unknown> | null {
152 if (value === null || typeof value !== "object" || Array.isArray(value)) {
153 return null;
154 }
155
156 return value as Record<string, unknown>;
157}
158
159function normalizeNonEmptyString(value: unknown): string | null {
160 if (typeof value !== "string") {
161 return null;
162 }
163
164 const normalized = value.trim();
165 return normalized === "" ? null : normalized;
166}
167
168function inferNodePlatformFromClientId(clientId: string): string | null {
169 const normalized = clientId.trim().toLowerCase();
170
171 if (normalized.startsWith("firefox-")) {
172 return "firefox";
173 }
174
175 if (normalized.startsWith("safari-")) {
176 return "safari";
177 }
178
179 return null;
180}
181
182function resolveNodePlatform(input: {
183 clientId: string;
184 nodePlatform: string | null;
185}): string | null {
186 const explicit = normalizeNonEmptyString(input.nodePlatform);
187
188 if (explicit != null) {
189 return explicit;
190 }
191
192 return inferNodePlatformFromClientId(input.clientId);
193}
194
195function readFirstString(
196 input: Record<string, unknown>,
197 keys: readonly string[]
198): string | null {
199 for (const key of keys) {
200 const value = normalizeNonEmptyString(input[key]);
201
202 if (value != null) {
203 return value;
204 }
205 }
206
207 return null;
208}
209
210function readOptionalBoolean(
211 input: Record<string, unknown>,
212 keys: readonly string[]
213): boolean | null {
214 for (const key of keys) {
215 const value = input[key];
216
217 if (typeof value === "boolean") {
218 return value;
219 }
220 }
221
222 return null;
223}
224
225function readOptionalInteger(
226 input: Record<string, unknown>,
227 keys: readonly string[]
228): number | null {
229 for (const key of keys) {
230 const value = input[key];
231
232 if (typeof value === "number" && Number.isFinite(value)) {
233 return Math.round(value);
234 }
235 }
236
237 return null;
238}
239
240function buildDeliveryRouteSnapshot(
241 message: Record<string, unknown>,
242 fallback: {
243 assistantMessageId: string;
244 conversationId: string | null;
245 observedAt: number;
246 platform: string;
247 }
248): BaaDeliveryRouteSnapshot | null {
249 const tabId = readOptionalInteger(message, ["tab_id", "tabId"]);
250 const pageUrl = readFirstString(message, ["page_url", "pageUrl"]);
251 const pageTitle = readFirstString(message, ["page_title", "pageTitle"]);
252 const shellPage = readOptionalBoolean(message, ["shell_page", "shellPage"]) === true;
253 const organizationId = readFirstString(message, ["organization_id", "organizationId"]);
254
255 if (tabId == null && pageUrl == null && pageTitle == null && organizationId == null && !shellPage) {
256 return null;
257 }
258
259 return {
260 assistantMessageId: fallback.assistantMessageId,
261 conversationId: fallback.conversationId,
262 observedAt: fallback.observedAt,
263 organizationId,
264 pageTitle,
265 pageUrl,
266 platform: fallback.platform,
267 shellPage,
268 tabId
269 };
270}
271
272function readStringArray(
273 input: Record<string, unknown>,
274 key: string
275): string[] {
276 const rawValue = input[key];
277
278 if (!Array.isArray(rawValue)) {
279 return [];
280 }
281
282 const values = new Set<string>();
283
284 for (const entry of rawValue) {
285 const value = normalizeNonEmptyString(entry);
286
287 if (value != null) {
288 values.add(value);
289 }
290 }
291
292 return [...values].sort((left, right) => left.localeCompare(right));
293}
294
295function hasExtractedBaaInstructionBlocks(text: string): boolean {
296 try {
297 return extractBaaInstructionBlocks(text).length > 0;
298 } catch {
299 return true;
300 }
301}
302
303function readEndpointMetadataArray(
304 input: Record<string, unknown>,
305 key: string
306): FirefoxBrowserEndpointMetadataSummary[] {
307 const rawValue = input[key];
308
309 if (!Array.isArray(rawValue)) {
310 return [];
311 }
312
313 const metadata: FirefoxBrowserEndpointMetadataSummary[] = [];
314
315 for (const entry of rawValue) {
316 const record = asRecord(entry);
317
318 if (record == null) {
319 continue;
320 }
321
322 const path = readFirstString(record, ["path"]);
323
324 if (path == null) {
325 continue;
326 }
327
328 metadata.push({
329 firstSeenAt: readOptionalTimestampMilliseconds(record, ["first_seen_at", "firstSeenAt"]),
330 lastSeenAt: readOptionalTimestampMilliseconds(record, ["last_seen_at", "lastSeenAt"]),
331 method: readFirstString(record, ["method"]),
332 path
333 });
334 }
335
336 metadata.sort((left, right) => {
337 const leftKey = `${left.method ?? ""} ${left.path}`;
338 const rightKey = `${right.method ?? ""} ${right.path}`;
339 return leftKey.localeCompare(rightKey);
340 });
341
342 return metadata;
343}
344
345function countObjectKeys(value: unknown): number {
346 const record = asRecord(value);
347
348 if (record == null) {
349 return 0;
350 }
351
352 return Object.keys(record).length;
353}
354
355function readOptionalTimestampMilliseconds(
356 input: Record<string, unknown>,
357 keys: readonly string[]
358): number | null {
359 for (const key of keys) {
360 const value = input[key];
361
362 if (typeof value === "number" && Number.isFinite(value) && value > 0) {
363 return Math.round(value);
364 }
365 }
366
367 return null;
368}
369
370function toUnixMilliseconds(value: number | null | undefined): number | null {
371 if (value == null) {
372 return null;
373 }
374
375 return value * 1000;
376}
377
378function toUnixSecondsMilliseconds(value: number | null | undefined): number | null {
379 if (value == null) {
380 return null;
381 }
382
383 return Math.floor(value / 1000);
384}
385
386function countHeaderNames(input: Record<string, unknown>): number {
387 const headerCount = countObjectKeys(input.headers);
388
389 if (headerCount > 0) {
390 return headerCount;
391 }
392
393 return readStringArray(input, "header_names").length;
394}
395
396function normalizeShellRuntimeSnapshot(
397 value: unknown,
398 fallbackPlatform: string | null = null
399): BrowserBridgeShellRuntimeSnapshot | null {
400 const record = asRecord(value);
401
402 if (record == null) {
403 return null;
404 }
405
406 const platform = readFirstString(record, ["platform"]) ?? fallbackPlatform;
407
408 if (platform == null) {
409 return null;
410 }
411
412 const desired = asRecord(record.desired) ?? {};
413 const actual = asRecord(record.actual) ?? {};
414 const drift = asRecord(record.drift) ?? {};
415
416 return {
417 actual: {
418 active: readOptionalBoolean(actual, ["active"]),
419 candidate_tab_id: readOptionalInteger(actual, ["candidate_tab_id", "candidateTabId"]),
420 candidate_url: readFirstString(actual, ["candidate_url", "candidateUrl"]),
421 discarded: readOptionalBoolean(actual, ["discarded"]),
422 exists: readOptionalBoolean(actual, ["exists"]) === true,
423 healthy: readOptionalBoolean(actual, ["healthy"]),
424 hidden: readOptionalBoolean(actual, ["hidden"]),
425 issue: readFirstString(actual, ["issue"]),
426 last_ready_at: readOptionalTimestampMilliseconds(actual, ["last_ready_at", "lastReadyAt"]),
427 last_seen_at: readOptionalTimestampMilliseconds(actual, ["last_seen_at", "lastSeenAt"]),
428 status: readFirstString(actual, ["status"]),
429 tab_id: readOptionalInteger(actual, ["tab_id", "tabId"]),
430 title: readFirstString(actual, ["title"]),
431 url: readFirstString(actual, ["url"]),
432 window_id: readOptionalInteger(actual, ["window_id", "windowId"])
433 },
434 desired: {
435 exists: readOptionalBoolean(desired, ["exists"]) === true,
436 last_action: readFirstString(desired, ["last_action", "lastAction"]),
437 last_action_at: readOptionalTimestampMilliseconds(desired, ["last_action_at", "lastActionAt"]),
438 reason: readFirstString(desired, ["reason"]),
439 shell_url: readFirstString(desired, ["shell_url", "shellUrl"]),
440 source: readFirstString(desired, ["source"]),
441 updated_at: readOptionalTimestampMilliseconds(desired, ["updated_at", "updatedAt"])
442 },
443 drift: {
444 aligned: readOptionalBoolean(drift, ["aligned"]) !== false,
445 needs_restore: readOptionalBoolean(drift, ["needs_restore", "needsRestore"]) === true,
446 reason: readFirstString(drift, ["reason"]),
447 unexpected_actual: readOptionalBoolean(drift, ["unexpected_actual", "unexpectedActual"]) === true
448 },
449 platform
450 };
451}
452
453function readShellRuntimeArray(
454 value: unknown,
455 fallbackPlatform: string | null = null
456): BrowserBridgeShellRuntimeSnapshot[] {
457 const runtimes = new Map<string, BrowserBridgeShellRuntimeSnapshot>();
458
459 const append = (
460 entry: unknown,
461 nextFallbackPlatform: string | null = fallbackPlatform
462 ): void => {
463 const runtime = normalizeShellRuntimeSnapshot(entry, nextFallbackPlatform);
464
465 if (runtime != null) {
466 runtimes.set(runtime.platform, runtime);
467 }
468 };
469
470 if (Array.isArray(value)) {
471 for (const entry of value) {
472 append(entry, null);
473 }
474 } else {
475 append(value, fallbackPlatform);
476 }
477
478 return [...runtimes.values()].sort((left, right) => left.platform.localeCompare(right.platform));
479}
480
481function mergeShellRuntimeSnapshots(
482 ...groups: readonly BrowserBridgeShellRuntimeSnapshot[][]
483): BrowserBridgeShellRuntimeSnapshot[] {
484 const runtimes = new Map<string, BrowserBridgeShellRuntimeSnapshot>();
485
486 for (const group of groups) {
487 for (const runtime of group) {
488 runtimes.set(runtime.platform, runtime);
489 }
490 }
491
492 return [...runtimes.values()].sort((left, right) => left.platform.localeCompare(right.platform));
493}
494
495function normalizeActionResultItem(
496 value: unknown,
497 fallbackPlatform: string | null = null
498): BrowserBridgeActionResultItemSnapshot | null {
499 const record = asRecord(value);
500
501 if (record == null) {
502 return null;
503 }
504
505 const platform = readFirstString(record, ["platform"]) ?? fallbackPlatform;
506 const runtimes = readShellRuntimeArray(record.shell_runtime ?? record.shellRuntime, platform);
507
508 return {
509 delivery_ack: normalizeDeliveryAck(record.delivery_ack ?? record.deliveryAck),
510 ok: readOptionalBoolean(record, ["ok"]) !== false,
511 platform: platform ?? runtimes[0]?.platform ?? null,
512 restored: readOptionalBoolean(record, ["restored"]),
513 shell_runtime: runtimes[0] ?? null,
514 skipped: readFirstString(record, ["skipped"]),
515 tab_id: readOptionalInteger(record, ["tab_id", "tabId"])
516 };
517}
518
519function normalizeDeliveryAck(value: unknown): BrowserBridgeDeliveryAckSnapshot | null {
520 const record = asRecord(value);
521
522 if (record == null) {
523 return null;
524 }
525
526 const level = readOptionalInteger(record, ["level"]);
527 let normalizedLevel: 0 | 1 | 2 | 3 = 0;
528
529 if (level === 1 || level === 2 || level === 3) {
530 normalizedLevel = level;
531 }
532
533 return {
534 confirmed_at: readOptionalTimestampMilliseconds(record, ["confirmed_at", "confirmedAt"]),
535 failed: readOptionalBoolean(record, ["failed"]) === true,
536 level: normalizedLevel,
537 reason: readFirstString(record, ["reason"]),
538 status_code: readOptionalInteger(record, ["status_code", "statusCode"])
539 };
540}
541
542function buildActionResultSummary(
543 record: Record<string, unknown> | null,
544 results: BrowserBridgeActionResultItemSnapshot[],
545 shellRuntime: BrowserBridgeShellRuntimeSnapshot[]
546): BrowserBridgeActionResultSummarySnapshot {
547 const skippedReasons = new Set<string>();
548
549 for (const result of results) {
550 if (result.skipped != null) {
551 skippedReasons.add(result.skipped);
552 }
553 }
554
555 if (record != null) {
556 const providedSkippedReasons = [
557 ...readStringArray(record, "skipped_reasons"),
558 ...readStringArray(record, "skippedReasons")
559 ];
560
561 for (const skippedReason of providedSkippedReasons) {
562 skippedReasons.add(skippedReason);
563 }
564 }
565
566 return {
567 actual_count:
568 readOptionalInteger(record ?? {}, ["actual_count", "actualCount"])
569 ?? shellRuntime.filter((runtime) => runtime.actual.exists).length,
570 desired_count:
571 readOptionalInteger(record ?? {}, ["desired_count", "desiredCount"])
572 ?? shellRuntime.filter((runtime) => runtime.desired.exists).length,
573 drift_count:
574 readOptionalInteger(record ?? {}, ["drift_count", "driftCount"])
575 ?? shellRuntime.filter((runtime) => runtime.drift.aligned === false).length,
576 failed_count:
577 readOptionalInteger(record ?? {}, ["failed_count", "failedCount"])
578 ?? results.filter((result) => result.ok === false).length,
579 ok_count:
580 readOptionalInteger(record ?? {}, ["ok_count", "okCount"])
581 ?? results.filter((result) => result.ok).length,
582 platform_count:
583 readOptionalInteger(record ?? {}, ["platform_count", "platformCount"])
584 ?? new Set([
585 ...results.map((result) => result.platform).filter((value): value is string => value != null),
586 ...shellRuntime.map((runtime) => runtime.platform)
587 ]).size,
588 restored_count:
589 readOptionalInteger(record ?? {}, ["restored_count", "restoredCount"])
590 ?? results.filter((result) => result.restored === true).length,
591 skipped_reasons: [...skippedReasons].sort((left, right) => left.localeCompare(right))
592 };
593}
594
595function normalizeActionResultTarget(
596 connection: FirefoxWebSocketConnection,
597 value: unknown,
598 fallbackPlatform: string | null = null
599): BrowserBridgeActionResultTargetSnapshot {
600 const record = asRecord(value) ?? {};
601
602 return {
603 client_id: connection.getClientId(),
604 connection_id: connection.getConnectionId(),
605 platform: readFirstString(record, ["platform"]) ?? fallbackPlatform,
606 requested_client_id:
607 readFirstString(record, ["requested_client_id", "requestedClientId"])
608 ?? connection.getClientId(),
609 requested_platform:
610 readFirstString(record, ["requested_platform", "requestedPlatform"])
611 ?? fallbackPlatform
612 };
613}
614
615function getLatestEndpointTimestamp(
616 updatedAt: number | null,
617 endpointMetadata: FirefoxBrowserEndpointMetadataSummary[],
618 fallback: number
619): number {
620 const metadataTimestamp = endpointMetadata.reduce<number>(
621 (current, entry) => Math.max(current, entry.lastSeenAt ?? entry.firstSeenAt ?? 0),
622 0
623 );
624
625 return Math.max(updatedAt ?? 0, metadataTimestamp, fallback);
626}
627
628function getPersistedLoginStateStatus(
629 messageStatus: BrowserBridgeLoginStatus | null | undefined
630): BrowserBridgeLoginStatus {
631 return messageStatus ?? "fresh";
632}
633
634function getAgedLoginStateStatus(
635 lastMessageAt: number,
636 nowMs: number
637): BrowserBridgeLoginStatus | null {
638 const ageMs = Math.max(0, nowMs - lastMessageAt);
639
640 if (ageMs >= FIREFOX_WS_LOGIN_STATE_LOST_AFTER_MS) {
641 return "lost";
642 }
643
644 if (ageMs >= FIREFOX_WS_LOGIN_STATE_STALE_AFTER_MS) {
645 return "stale";
646 }
647
648 return null;
649}
650
651function getBrowserLoginStateRank(status: BrowserBridgeLoginStatus): number {
652 switch (status) {
653 case "fresh":
654 return 0;
655 case "stale":
656 return 1;
657 case "lost":
658 return 2;
659 }
660}
661
662function normalizePathname(value: string): string {
663 const normalized = value.replace(/\/+$/u, "");
664 return normalized === "" ? "/" : normalized;
665}
666
667function buildWebSocketAcceptValue(key: string): string {
668 return createHash("sha1").update(`${key}${FIREFOX_WS_GUID}`).digest("base64");
669}
670
671function buildClosePayload(code: number, reason: string): Buffer {
672 const reasonBuffer = Buffer.from(reason, "utf8");
673 const payload = Buffer.allocUnsafe(2 + reasonBuffer.length);
674 payload.writeUInt16BE(code, 0);
675 reasonBuffer.copy(payload, 2);
676 return payload;
677}
678
679function buildFrame(opcode: number, payload: Buffer = Buffer.alloc(0)): Buffer {
680 let header: Buffer;
681
682 if (payload.length < 126) {
683 header = Buffer.allocUnsafe(2);
684 header[0] = 0x80 | opcode;
685 header[1] = payload.length;
686 return Buffer.concat([header, payload]);
687 }
688
689 if (payload.length <= 0xffff) {
690 header = Buffer.allocUnsafe(4);
691 header[0] = 0x80 | opcode;
692 header[1] = 126;
693 header.writeUInt16BE(payload.length, 2);
694 return Buffer.concat([header, payload]);
695 }
696
697 header = Buffer.allocUnsafe(10);
698 header[0] = 0x80 | opcode;
699 header[1] = 127;
700 header.writeBigUInt64BE(BigInt(payload.length), 2);
701 return Buffer.concat([header, payload]);
702}
703
704export function buildBrowserWebSocketUrl(localApiBase: string | null | undefined): string | null {
705 const normalized = normalizeNonEmptyString(localApiBase);
706
707 if (normalized == null) {
708 return null;
709 }
710
711 const url = new URL(normalized);
712 url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
713 url.pathname = BROWSER_WS_PATH;
714 url.search = "";
715 url.hash = "";
716 return url.toString();
717}
718
719export function buildFirefoxWebSocketUrl(localApiBase: string | null | undefined): string | null {
720 const normalized = normalizeNonEmptyString(localApiBase);
721
722 if (normalized == null) {
723 return null;
724 }
725
726 const url = new URL(normalized);
727 url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
728 url.pathname = FIREFOX_WS_PATH;
729 url.search = "";
730 url.hash = "";
731 return url.toString();
732}
733
734class FirefoxWebSocketConnection {
735 readonly session: FirefoxBrowserSession;
736 private closed = false;
737 private buffer = Buffer.alloc(0);
738 private closeCode: number | null = null;
739 private closeReason: string | null = null;
740
741 constructor(
742 private readonly socket: Socket,
743 private readonly server: ConductorFirefoxWebSocketServer
744 ) {
745 const now = this.server.getNextTimestampMilliseconds();
746
747 this.session = {
748 clientId: null,
749 connectedAt: now,
750 credentials: new Map(),
751 finalMessages: [],
752 id: randomUUID(),
753 lastActionResult: null,
754 lastMessageAt: now,
755 nodeCategory: null,
756 nodePlatform: null,
757 nodeType: null,
758 requestHooks: new Map(),
759 shellRuntime: new Map()
760 };
761
762 this.socket.setNoDelay(true);
763 this.socket.on("data", (chunk) => {
764 this.handleData(chunk);
765 });
766 this.socket.on("close", () => {
767 this.handleClosed();
768 });
769 this.socket.on("end", () => {
770 this.handleClosed();
771 });
772 this.socket.on("error", () => {
773 this.handleClosed();
774 });
775 }
776
777 attachHead(head: Buffer): void {
778 if (head.length > 0) {
779 this.handleData(head);
780 }
781 }
782
783 getClientId(): string | null {
784 return this.session.clientId;
785 }
786
787 getConnectionId(): string {
788 return this.session.id;
789 }
790
791 getCloseInfo(): { code: number | null; reason: string | null } {
792 return {
793 code: this.closeCode,
794 reason: this.closeReason
795 };
796 }
797
798 setClientMetadata(metadata: {
799 clientId: string;
800 nodeCategory: string | null;
801 nodePlatform: string | null;
802 nodeType: string | null;
803 }): void {
804 this.session.clientId = metadata.clientId;
805 this.session.nodeCategory = metadata.nodeCategory;
806 this.session.nodePlatform = metadata.nodePlatform;
807 this.session.nodeType = metadata.nodeType;
808 }
809
810 updateCredential(platform: string, summary: FirefoxBrowserCredentialSummary): void {
811 this.session.credentials.set(platform, summary);
812 }
813
814 getCredential(platform: string): FirefoxBrowserCredentialSummary | null {
815 return this.session.credentials.get(platform) ?? null;
816 }
817
818 updateRequestHook(platform: string, summary: FirefoxBrowserHookSummary): void {
819 this.session.requestHooks.set(platform, summary);
820 }
821
822 getRequestHook(platform: string): FirefoxBrowserHookSummary | null {
823 return this.session.requestHooks.get(platform) ?? null;
824 }
825
826 updateShellRuntime(summary: BrowserBridgeShellRuntimeSnapshot): void {
827 this.session.shellRuntime.set(summary.platform, summary);
828 }
829
830 getShellRuntime(platform: string): BrowserBridgeShellRuntimeSnapshot | null {
831 return this.session.shellRuntime.get(platform) ?? null;
832 }
833
834 setLastActionResult(result: BrowserBridgeActionResultSnapshot): void {
835 this.session.lastActionResult = result;
836 }
837
838 addFinalMessage(message: BrowserBridgeFinalMessageSnapshot): void {
839 const dedupeKey = [
840 message.platform,
841 message.conversation_id ?? "",
842 message.assistant_message_id,
843 message.raw_text
844 ].join("|");
845 const existingIndex = this.session.finalMessages.findIndex((entry) =>
846 [
847 entry.platform,
848 entry.conversation_id ?? "",
849 entry.assistant_message_id,
850 entry.raw_text
851 ].join("|") === dedupeKey
852 );
853
854 if (existingIndex >= 0) {
855 this.session.finalMessages.splice(existingIndex, 1);
856 }
857
858 this.session.finalMessages.push(message);
859 if (this.session.finalMessages.length > 10) {
860 this.session.finalMessages.splice(0, this.session.finalMessages.length - 10);
861 }
862 }
863
864 touch(): void {
865 this.session.lastMessageAt = this.server.getNextTimestampMilliseconds();
866 }
867
868 describe(): BrowserBridgeClientSnapshot {
869 const credentials = [...this.session.credentials.entries()]
870 .sort(([left], [right]) => left.localeCompare(right))
871 .map(([platform, summary]) => ({
872 account: summary.account,
873 account_captured_at: summary.accountCapturedAt,
874 account_last_seen_at: summary.accountLastSeenAt,
875 platform,
876 captured_at: summary.capturedAt,
877 credential_fingerprint: summary.credentialFingerprint,
878 freshness: summary.freshness,
879 header_count: summary.headerCount,
880 last_seen_at: summary.lastSeenAt
881 }));
882 const requestHooks = [...this.session.requestHooks.entries()]
883 .sort(([left], [right]) => left.localeCompare(right))
884 .map(([platform, summary]) => ({
885 account: summary.account,
886 credential_fingerprint: summary.credentialFingerprint,
887 platform,
888 endpoint_count: summary.endpoints.length,
889 endpoint_metadata: summary.endpointMetadata.map((entry): BrowserBridgeEndpointMetadataSnapshot => ({
890 first_seen_at: entry.firstSeenAt,
891 last_seen_at: entry.lastSeenAt,
892 method: entry.method,
893 path: entry.path
894 })),
895 endpoints: [...summary.endpoints],
896 last_verified_at: summary.lastVerifiedAt,
897 updated_at: summary.updatedAt
898 }));
899 const shellRuntime = [...this.session.shellRuntime.values()]
900 .sort((left, right) => left.platform.localeCompare(right.platform))
901 .map((entry) => ({
902 actual: { ...entry.actual },
903 desired: { ...entry.desired },
904 drift: { ...entry.drift },
905 platform: entry.platform
906 }));
907
908 return {
909 client_id: this.session.clientId ?? `anonymous-${this.session.id.slice(0, 8)}`,
910 connected_at: this.session.connectedAt,
911 connection_id: this.session.id,
912 credentials,
913 final_messages: this.session.finalMessages.map((entry) => ({ ...entry })),
914 last_action_result: this.session.lastActionResult,
915 last_message_at: this.session.lastMessageAt,
916 node_category: this.session.nodeCategory,
917 node_platform: this.session.nodePlatform,
918 node_type: this.session.nodeType,
919 request_hooks: requestHooks,
920 shell_runtime: shellRuntime
921 };
922 }
923
924 sendJson(payload: Record<string, unknown>): boolean {
925 if (this.closed) {
926 return false;
927 }
928
929 try {
930 const body = Buffer.from(`${JSON.stringify(payload)}\n`, "utf8");
931 this.socket.write(buildFrame(0x1, body));
932 return true;
933 } catch {
934 this.handleClosed();
935 return false;
936 }
937 }
938
939 close(code: number = NORMAL_CLOSE_CODE, reason: string = ""): void {
940 if (this.closed) {
941 return;
942 }
943
944 this.closed = true;
945 this.closeCode = code;
946 this.closeReason = reason;
947
948 try {
949 this.socket.write(buildFrame(0x8, buildClosePayload(code, reason)));
950 } catch {
951 // Best-effort close frame.
952 }
953
954 this.socket.end();
955 this.socket.destroySoon?.();
956 this.server.unregister(this);
957 }
958
959 private handleClosed(): void {
960 if (this.closed) {
961 return;
962 }
963
964 this.closed = true;
965 this.socket.destroy();
966 this.server.unregister(this);
967 }
968
969 private handleData(chunk: Buffer): void {
970 if (this.closed) {
971 return;
972 }
973
974 this.buffer = Buffer.concat([this.buffer, chunk]);
975
976 while (true) {
977 const frame = this.readFrame();
978
979 if (frame == null) {
980 return;
981 }
982
983 if (frame.opcode === 0x8) {
984 this.close();
985 return;
986 }
987
988 if (frame.opcode === 0x9) {
989 this.socket.write(buildFrame(0xA, frame.payload));
990 continue;
991 }
992
993 if (frame.opcode === 0xA) {
994 continue;
995 }
996
997 if (frame.opcode !== 0x1) {
998 this.close(UNSUPPORTED_DATA_CLOSE_CODE, "Only text frames are supported.");
999 return;
1000 }
1001
1002 this.touch();
1003 this.server.handleClientMessage(this, frame.payload.toString("utf8"));
1004 }
1005 }
1006
1007 private readFrame(): { opcode: number; payload: Buffer } | null {
1008 if (this.buffer.length < 2) {
1009 return null;
1010 }
1011
1012 const firstByte = this.buffer[0];
1013 const secondByte = this.buffer[1];
1014
1015 if (firstByte == null || secondByte == null) {
1016 return null;
1017 }
1018
1019 const fin = (firstByte & 0x80) !== 0;
1020 const opcode = firstByte & 0x0f;
1021 const masked = (secondByte & 0x80) !== 0;
1022 let payloadLength = secondByte & 0x7f;
1023 let offset = 2;
1024
1025 if (!fin) {
1026 this.close(UNSUPPORTED_DATA_CLOSE_CODE, "Fragmented frames are not supported.");
1027 return null;
1028 }
1029
1030 if (!masked) {
1031 this.close(INVALID_MESSAGE_CLOSE_CODE, "Client frames must be masked.");
1032 return null;
1033 }
1034
1035 if (payloadLength === 126) {
1036 if (this.buffer.length < offset + 2) {
1037 return null;
1038 }
1039
1040 payloadLength = this.buffer.readUInt16BE(offset);
1041 offset += 2;
1042 } else if (payloadLength === 127) {
1043 if (this.buffer.length < offset + 8) {
1044 return null;
1045 }
1046
1047 const extendedLength = this.buffer.readBigUInt64BE(offset);
1048
1049 if (extendedLength > BigInt(MAX_FRAME_PAYLOAD_BYTES)) {
1050 this.close(MESSAGE_TOO_LARGE_CLOSE_CODE, "Frame payload is too large.");
1051 return null;
1052 }
1053
1054 payloadLength = Number(extendedLength);
1055 offset += 8;
1056 }
1057
1058 if (payloadLength > MAX_FRAME_PAYLOAD_BYTES) {
1059 this.close(MESSAGE_TOO_LARGE_CLOSE_CODE, "Frame payload is too large.");
1060 return null;
1061 }
1062
1063 if (this.buffer.length < offset + 4 + payloadLength) {
1064 return null;
1065 }
1066
1067 const mask = this.buffer.subarray(offset, offset + 4);
1068 offset += 4;
1069 const payload = this.buffer.subarray(offset, offset + payloadLength);
1070 const unmasked = Buffer.allocUnsafe(payloadLength);
1071
1072 for (let index = 0; index < payloadLength; index += 1) {
1073 const maskByte = mask[index % 4];
1074 const payloadByte = payload[index];
1075
1076 if (maskByte == null || payloadByte == null) {
1077 this.close(INVALID_MESSAGE_CLOSE_CODE, "Malformed masked payload.");
1078 return null;
1079 }
1080
1081 unmasked[index] = payloadByte ^ maskByte;
1082 }
1083
1084 this.buffer = this.buffer.subarray(offset + payloadLength);
1085 return {
1086 opcode,
1087 payload: unmasked
1088 };
1089 }
1090}
1091
1092export class ConductorFirefoxWebSocketServer {
1093 private readonly artifactStore: ArtifactStore | null;
1094 private readonly baseUrlLoader: () => string;
1095 private readonly bridgeService: FirefoxBridgeService;
1096 private readonly deliveryBridge: BaaBrowserDeliveryBridge;
1097 private readonly ingestLogDir: string | null;
1098 private readonly instructionIngest: BaaLiveInstructionIngest | null;
1099 private readonly now: () => number;
1100 private readonly pluginDiagnosticLogDir: string | null;
1101 private readonly repository: ControlPlaneRepository;
1102 private readonly snapshotLoader: () => ConductorRuntimeSnapshot;
1103 private readonly connections = new Set<FirefoxWebSocketConnection>();
1104 private readonly connectionsByClientId = new Map<string, FirefoxWebSocketConnection>();
1105 private automationConversationsSnapshot: BrowserBridgeConversationAutomationSnapshot[] = [];
1106 private broadcastQueue: Promise<void> = Promise.resolve();
1107 private lastSnapshotSignature: string | null = null;
1108 private lastTimestampMs = 0;
1109 private pollTimer: IntervalHandle | null = null;
1110
1111 constructor(options: FirefoxWebSocketServerOptions) {
1112 this.artifactStore = options.artifactStore ?? null;
1113 this.baseUrlLoader = options.baseUrlLoader;
1114 this.ingestLogDir = options.ingestLogDir ?? null;
1115 this.instructionIngest = options.instructionIngest ?? null;
1116 this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
1117 this.pluginDiagnosticLogDir = options.pluginDiagnosticLogDir ?? null;
1118 this.repository = options.repository;
1119 this.snapshotLoader = options.snapshotLoader;
1120 const commandBroker = new FirefoxCommandBroker({
1121 now: () => Date.now(),
1122 resolveActiveClient: () => this.getActiveClient(),
1123 resolveClientById: (clientId) => this.getClientById(clientId)
1124 });
1125 this.bridgeService = new FirefoxBridgeService(commandBroker);
1126 this.deliveryBridge = new BaaBrowserDeliveryBridge({
1127 inlineThreshold: options.artifactInlineThreshold,
1128 bridge: this.bridgeService,
1129 now: () => this.getNextTimestampMilliseconds(),
1130 onChange: () => this.broadcastStateSnapshot("delivery_session", {
1131 force: true
1132 }),
1133 summaryLength: options.artifactSummaryLength
1134 });
1135 }
1136
1137 getUrl(): string | null {
1138 return buildBrowserWebSocketUrl(this.baseUrlLoader());
1139 }
1140
1141 getFirefoxCompatUrl(): string | null {
1142 return buildFirefoxWebSocketUrl(this.baseUrlLoader());
1143 }
1144
1145 getBridgeService(): FirefoxBridgeService {
1146 return this.bridgeService;
1147 }
1148
1149 getDeliveryBridge(): BaaBrowserDeliveryBridge {
1150 return this.deliveryBridge;
1151 }
1152
1153 getNowMilliseconds(): number {
1154 return this.now() * 1000;
1155 }
1156
1157 getNextTimestampMilliseconds(): number {
1158 const nowMs = this.getNowMilliseconds();
1159 this.lastTimestampMs = Math.max(this.lastTimestampMs + 1, nowMs);
1160 return this.lastTimestampMs;
1161 }
1162
1163 getStateSnapshot(): BrowserBridgeStateSnapshot {
1164 return this.buildBrowserStateSnapshot();
1165 }
1166
1167 start(): void {
1168 if (this.pollTimer != null) {
1169 return;
1170 }
1171
1172 this.pollTimer = globalThis.setInterval(() => {
1173 void this.refreshBrowserLoginStateStatuses();
1174 void this.broadcastStateSnapshot("poll");
1175 }, FIREFOX_WS_STATE_POLL_INTERVAL_MS);
1176 }
1177
1178 async stop(): Promise<void> {
1179 if (this.pollTimer != null) {
1180 globalThis.clearInterval(this.pollTimer);
1181 this.pollTimer = null;
1182 }
1183
1184 this.bridgeService.stop();
1185 this.deliveryBridge.stop();
1186
1187 for (const connection of [...this.connections]) {
1188 connection.close(1001, "server shutdown");
1189 }
1190
1191 this.connections.clear();
1192 this.connectionsByClientId.clear();
1193 this.lastSnapshotSignature = null;
1194 await this.broadcastQueue.catch(() => {});
1195 }
1196
1197 handleUpgrade(request: IncomingMessage, socket: Socket, head: Buffer): boolean {
1198 const pathname = normalizePathname(new URL(request.url ?? "/", "http://127.0.0.1").pathname);
1199
1200 if (pathname !== BROWSER_WS_PATH && pathname !== FIREFOX_WS_PATH) {
1201 socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
1202 socket.destroy();
1203 return false;
1204 }
1205
1206 const upgrade = normalizeNonEmptyString(request.headers.upgrade);
1207 const key = normalizeNonEmptyString(request.headers["sec-websocket-key"]);
1208 const version = normalizeNonEmptyString(request.headers["sec-websocket-version"]);
1209
1210 if (request.method !== "GET" || upgrade?.toLowerCase() !== "websocket" || key == null) {
1211 socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
1212 socket.destroy();
1213 return false;
1214 }
1215
1216 if (version !== "13") {
1217 socket.write(
1218 "HTTP/1.1 426 Upgrade Required\r\nSec-WebSocket-Version: 13\r\nConnection: close\r\n\r\n"
1219 );
1220 socket.destroy();
1221 return false;
1222 }
1223
1224 socket.write(
1225 [
1226 "HTTP/1.1 101 Switching Protocols",
1227 "Upgrade: websocket",
1228 "Connection: Upgrade",
1229 `Sec-WebSocket-Accept: ${buildWebSocketAcceptValue(key)}`,
1230 "",
1231 ""
1232 ].join("\r\n")
1233 );
1234
1235 const connection = new FirefoxWebSocketConnection(socket, this);
1236 this.connections.add(connection);
1237 connection.attachHead(head);
1238 return true;
1239 }
1240
1241 unregister(connection: FirefoxWebSocketConnection): void {
1242 this.connections.delete(connection);
1243
1244 const clientId = connection.getClientId();
1245 const closeInfo = connection.getCloseInfo();
1246
1247 if (clientId != null && this.connectionsByClientId.get(clientId) === connection) {
1248 this.connectionsByClientId.delete(clientId);
1249 }
1250
1251 this.bridgeService.handleConnectionClosed({
1252 clientId,
1253 code: closeInfo.code,
1254 connectionId: connection.getConnectionId(),
1255 reason: closeInfo.reason
1256 });
1257 this.deliveryBridge.handleConnectionClosed(connection.getConnectionId(), closeInfo.reason);
1258 void this.markClientLoginStatesStale(clientId);
1259
1260 void this.broadcastStateSnapshot("disconnect", {
1261 force: true
1262 });
1263 }
1264
1265 handleClientMessage(connection: FirefoxWebSocketConnection, rawMessage: string): void {
1266 void this.dispatchClientMessage(connection, rawMessage);
1267 }
1268
1269 private async dispatchClientMessage(
1270 connection: FirefoxWebSocketConnection,
1271 rawMessage: string
1272 ): Promise<void> {
1273 let message: Record<string, unknown> | null = null;
1274
1275 try {
1276 message = asRecord(JSON.parse(rawMessage));
1277 } catch {
1278 this.sendError(connection, "invalid_json", "WS message body must be valid JSON.");
1279 return;
1280 }
1281
1282 if (message == null) {
1283 this.sendError(connection, "invalid_message", "WS message must be a JSON object.");
1284 return;
1285 }
1286
1287 const type = normalizeNonEmptyString(message.type);
1288
1289 if (type == null) {
1290 this.sendError(connection, "invalid_message", "WS message requires a non-empty type.");
1291 return;
1292 }
1293
1294 connection.touch();
1295
1296 switch (type) {
1297 case "hello":
1298 await this.handleHello(connection, message);
1299 return;
1300 case "state_request":
1301 await this.sendStateSnapshotTo(connection, "state_request");
1302 return;
1303 case "action_request":
1304 await this.handleActionRequest(connection, message);
1305 return;
1306 case "action_result":
1307 await this.handlePluginActionResult(connection, message);
1308 return;
1309 case "credentials":
1310 await this.handleCredentials(connection, message);
1311 return;
1312 case "api_endpoints":
1313 await this.handleApiEndpoints(connection, message);
1314 return;
1315 case "client_log":
1316 return;
1317 case "plugin_diagnostic_log":
1318 this.handlePluginDiagnosticLog(connection, message);
1319 return;
1320 case "browser.final_message":
1321 await this.handleBrowserFinalMessage(connection, message);
1322 return;
1323 case "api_request":
1324 this.sendError(
1325 connection,
1326 "unsupported_message_type",
1327 "api_request is a server-initiated browser bridge command and cannot be sent by the client."
1328 );
1329 return;
1330 case "api_response":
1331 this.handleApiResponse(connection, message);
1332 return;
1333 case "stream_open":
1334 this.handleStreamOpen(connection, message);
1335 return;
1336 case "stream_event":
1337 this.handleStreamEvent(connection, message);
1338 return;
1339 case "stream_end":
1340 this.handleStreamEnd(connection, message);
1341 return;
1342 case "stream_error":
1343 this.handleStreamError(connection, message);
1344 return;
1345 default:
1346 this.sendError(connection, "unsupported_message_type", `Unsupported WS message type: ${type}.`);
1347 }
1348 }
1349
1350 private async handleHello(
1351 connection: FirefoxWebSocketConnection,
1352 message: Record<string, unknown>
1353 ): Promise<void> {
1354 const clientId =
1355 readFirstString(message, ["clientId", "client_id"]) ?? `anonymous-${connection.session.id.slice(0, 8)}`;
1356 const previousConnection = this.connectionsByClientId.get(clientId);
1357
1358 if (previousConnection != null && previousConnection !== connection) {
1359 previousConnection.close(CLIENT_REPLACED_CLOSE_CODE, "replaced by a newer connection");
1360 }
1361
1362 connection.setClientMetadata({
1363 clientId,
1364 nodeCategory: readFirstString(message, ["nodeCategory", "node_category"]),
1365 nodePlatform: resolveNodePlatform({
1366 clientId,
1367 nodePlatform: readFirstString(message, ["nodePlatform", "node_platform"])
1368 }),
1369 nodeType: readFirstString(message, ["nodeType", "node_type"])
1370 });
1371 this.connectionsByClientId.set(clientId, connection);
1372
1373 connection.sendJson({
1374 type: "hello_ack",
1375 clientId,
1376 localApiBase: this.snapshotLoader().controlApi.localApiBase ?? null,
1377 protocol: BROWSER_WS_PROTOCOL,
1378 protocol_compat: [FIREFOX_WS_PROTOCOL],
1379 version: BROWSER_WS_PROTOCOL_VERSION,
1380 wsUrl: this.getUrl(),
1381 wsCompatUrls: [this.getFirefoxCompatUrl()].filter((entry): entry is string => entry != null),
1382 supports: {
1383 inbound: [
1384 "hello",
1385 "state_request",
1386 "action_request",
1387 "action_result",
1388 "credentials",
1389 "api_endpoints",
1390 "client_log",
1391 "plugin_diagnostic_log",
1392 "browser.final_message",
1393 "api_response",
1394 "stream_open",
1395 "stream_event",
1396 "stream_end",
1397 "stream_error"
1398 ],
1399 outbound: [
1400 "hello_ack",
1401 "state_snapshot",
1402 "action_result",
1403 "browser.inject_message",
1404 "browser.proxy_delivery",
1405 "browser.send_message",
1406 "request_credentials",
1407 "open_tab",
1408 "plugin_status",
1409 "ws_reconnect",
1410 "controller_reload",
1411 "tab_restore",
1412 "reload",
1413 "api_request",
1414 "request_cancel",
1415 "error"
1416 ]
1417 }
1418 });
1419 await this.sendStateSnapshotTo(connection, "hello");
1420 this.bridgeService.requestCredentials({
1421 clientId,
1422 reason: "hello"
1423 });
1424 await this.broadcastStateSnapshot("client_hello", {
1425 force: true
1426 });
1427 }
1428
1429 private async handleActionRequest(
1430 connection: FirefoxWebSocketConnection,
1431 message: Record<string, unknown>
1432 ): Promise<void> {
1433 const action = normalizeNonEmptyString(message.action);
1434 const requestId = readFirstString(message, ["requestId", "request_id", "id"]) ?? randomUUID();
1435
1436 if (action !== "pause" && action !== "resume" && action !== "drain") {
1437 connection.sendJson({
1438 type: "action_result",
1439 action,
1440 ok: false,
1441 requestId,
1442 error: "invalid_action",
1443 message: "action must be one of pause, resume, or drain."
1444 });
1445 return;
1446 }
1447
1448 const requestedBy =
1449 readFirstString(message, ["requestedBy", "requested_by"])
1450 ?? connection.getClientId()
1451 ?? "browser_admin";
1452 const reason =
1453 readFirstString(message, ["reason"])
1454 ?? `ws_${action}_requested`;
1455 const source =
1456 readFirstString(message, ["source"])
1457 ?? "firefox_extension_ws";
1458
1459 try {
1460 const system = await setAutomationMode(this.repository, {
1461 mode: mapActionToMode(action),
1462 reason,
1463 requestedBy,
1464 source,
1465 updatedAt: this.now()
1466 });
1467
1468 connection.sendJson({
1469 type: "action_result",
1470 action,
1471 ok: true,
1472 requestId,
1473 system
1474 });
1475 await this.broadcastStateSnapshot("action_request", {
1476 force: true
1477 });
1478 } catch (error) {
1479 connection.sendJson({
1480 type: "action_result",
1481 action,
1482 ok: false,
1483 requestId,
1484 error: "action_failed",
1485 message: error instanceof Error ? error.message : String(error)
1486 });
1487 }
1488 }
1489
1490 private async handlePluginActionResult(
1491 connection: FirefoxWebSocketConnection,
1492 message: Record<string, unknown>
1493 ): Promise<void> {
1494 const action = normalizeNonEmptyString(message.action);
1495 const requestId = readFirstString(message, ["requestId", "request_id", "id"]);
1496
1497 if (action == null || requestId == null) {
1498 this.sendError(
1499 connection,
1500 "invalid_message",
1501 "action_result requires non-empty action and requestId fields."
1502 );
1503 return;
1504 }
1505
1506 const fallbackPlatform = readFirstString(message, ["platform"]);
1507 const resultRecord = asRecord(message.result);
1508 const results = Array.isArray(message.results)
1509 ? message.results
1510 .map((entry) => normalizeActionResultItem(entry, fallbackPlatform))
1511 .filter((entry): entry is BrowserBridgeActionResultItemSnapshot => entry != null)
1512 : [];
1513 const shellRuntime = mergeShellRuntimeSnapshots(
1514 readShellRuntimeArray(message.shell_runtime ?? message.shellRuntime, fallbackPlatform),
1515 readShellRuntimeArray(resultRecord?.shell_runtime ?? resultRecord?.shellRuntime, fallbackPlatform),
1516 results
1517 .map((entry) => entry.shell_runtime)
1518 .filter((entry): entry is BrowserBridgeShellRuntimeSnapshot => entry != null)
1519 );
1520 const normalizedResult: BrowserBridgeActionResultSnapshot = {
1521 accepted: readOptionalBoolean(message, ["accepted"]) !== false,
1522 action,
1523 completed: readOptionalBoolean(message, ["completed"]) !== false,
1524 failed:
1525 readOptionalBoolean(message, ["failed"])
1526 ?? results.some((entry) => entry.ok === false),
1527 reason: readFirstString(message, ["reason", "message", "error"]),
1528 received_at: this.getNextTimestampMilliseconds(),
1529 request_id: requestId,
1530 result: buildActionResultSummary(resultRecord, results, shellRuntime),
1531 results: [...results].sort((left, right) =>
1532 String(left.platform ?? "").localeCompare(String(right.platform ?? ""))
1533 ),
1534 shell_runtime: shellRuntime,
1535 target: normalizeActionResultTarget(connection, message.target, fallbackPlatform),
1536 type:
1537 readFirstString(message, ["command_type", "commandType", "type_name", "typeName"])
1538 ?? action
1539 };
1540
1541 for (const runtime of normalizedResult.shell_runtime) {
1542 connection.updateShellRuntime(runtime);
1543 }
1544
1545 connection.setLastActionResult(normalizedResult);
1546 this.bridgeService.handleActionResult(connection.getConnectionId(), normalizedResult);
1547 await this.broadcastStateSnapshot("action_result", {
1548 force: true
1549 });
1550 }
1551
1552 private async handleCredentials(
1553 connection: FirefoxWebSocketConnection,
1554 message: Record<string, unknown>
1555 ): Promise<void> {
1556 const platform = readFirstString(message, ["platform"]);
1557 const nowMs = this.getNowMilliseconds();
1558
1559 if (platform == null) {
1560 this.sendError(connection, "invalid_message", "credentials requires a platform field.");
1561 return;
1562 }
1563
1564 const capturedAt =
1565 readOptionalTimestampMilliseconds(message, ["captured_at", "capturedAt"])
1566 ?? readOptionalTimestampMilliseconds(message, ["timestamp"])
1567 ?? nowMs;
1568 const lastSeenAt =
1569 readOptionalTimestampMilliseconds(message, ["last_seen_at", "lastSeenAt"])
1570 ?? capturedAt;
1571 const credentialSummary: FirefoxBrowserCredentialSummary = {
1572 account: readFirstString(message, ["account"]),
1573 accountCapturedAt: readOptionalTimestampMilliseconds(message, ["account_captured_at", "accountCapturedAt"]),
1574 accountLastSeenAt: readOptionalTimestampMilliseconds(message, ["account_last_seen_at", "accountLastSeenAt"]),
1575 capturedAt,
1576 credentialFingerprint: readFirstString(message, ["credential_fingerprint", "credentialFingerprint"]),
1577 freshness: normalizeBrowserLoginStatus(message.freshness) ?? "fresh",
1578 headerCount: countHeaderNames(message),
1579 lastSeenAt
1580 };
1581 const shellRuntime = readShellRuntimeArray(message.shell_runtime, platform)[0] ?? null;
1582
1583 connection.updateCredential(platform, credentialSummary);
1584
1585 if (shellRuntime != null) {
1586 connection.updateShellRuntime(shellRuntime);
1587 }
1588
1589 const requestHook = connection.getRequestHook(platform);
1590
1591 if (requestHook != null) {
1592 connection.updateRequestHook(platform, {
1593 ...requestHook,
1594 account: requestHook.account ?? credentialSummary.account,
1595 credentialFingerprint: requestHook.credentialFingerprint ?? credentialSummary.credentialFingerprint
1596 });
1597 }
1598
1599 await this.persistCredentialSnapshot(connection, platform, credentialSummary);
1600 await this.refreshBrowserLoginStateStatuses();
1601 await this.broadcastStateSnapshot("credentials");
1602 }
1603
1604 private async persistCredentialSnapshot(
1605 connection: FirefoxWebSocketConnection,
1606 platform: string,
1607 summary: FirefoxBrowserCredentialSummary
1608 ): Promise<void> {
1609 const clientId = connection.getClientId();
1610 const fingerprint = summary.credentialFingerprint;
1611
1612 if (clientId == null || summary.account == null || fingerprint == null) {
1613 return;
1614 }
1615
1616 const capturedAt = toUnixSecondsMilliseconds(summary.capturedAt);
1617 const lastSeenAt = toUnixSecondsMilliseconds(summary.lastSeenAt ?? summary.capturedAt);
1618
1619 if (capturedAt == null || lastSeenAt == null) {
1620 return;
1621 }
1622
1623 const runtime = this.snapshotLoader();
1624 await this.repository.upsertBrowserLoginState({
1625 account: summary.account,
1626 browser:
1627 connection.session.nodePlatform
1628 ?? inferNodePlatformFromClientId(clientId)
1629 ?? "firefox",
1630 capturedAt,
1631 clientId,
1632 credentialFingerprint: fingerprint,
1633 host: runtime.daemon.host,
1634 lastSeenAt,
1635 platform,
1636 status: getPersistedLoginStateStatus(summary.freshness)
1637 });
1638 }
1639
1640 private async handleApiEndpoints(
1641 connection: FirefoxWebSocketConnection,
1642 message: Record<string, unknown>
1643 ): Promise<void> {
1644 const platform = readFirstString(message, ["platform"]);
1645 const nowMs = this.getNowMilliseconds();
1646
1647 if (platform == null) {
1648 this.sendError(connection, "invalid_message", "api_endpoints requires a platform field.");
1649 return;
1650 }
1651
1652 const endpointMetadata = readEndpointMetadataArray(message, "endpoint_metadata");
1653 const credentialSummary = connection.getCredential(platform);
1654 const requestHookSummary: FirefoxBrowserHookSummary = {
1655 account:
1656 readFirstString(message, ["account"])
1657 ?? credentialSummary?.account
1658 ?? null,
1659 credentialFingerprint:
1660 readFirstString(message, ["credential_fingerprint", "credentialFingerprint"])
1661 ?? credentialSummary?.credentialFingerprint
1662 ?? null,
1663 endpointMetadata,
1664 endpoints: readStringArray(message, "endpoints"),
1665 lastVerifiedAt: getLatestEndpointTimestamp(
1666 readOptionalTimestampMilliseconds(message, ["updated_at", "updatedAt"]),
1667 endpointMetadata,
1668 nowMs
1669 ),
1670 updatedAt: getLatestEndpointTimestamp(
1671 readOptionalTimestampMilliseconds(message, ["updated_at", "updatedAt"]),
1672 endpointMetadata,
1673 nowMs
1674 )
1675 };
1676 const shellRuntime = readShellRuntimeArray(message.shell_runtime, platform)[0] ?? null;
1677
1678 connection.updateRequestHook(platform, requestHookSummary);
1679
1680 if (shellRuntime != null) {
1681 connection.updateShellRuntime(shellRuntime);
1682 }
1683 await this.persistEndpointSnapshot(connection, platform, requestHookSummary);
1684 await this.broadcastStateSnapshot("api_endpoints");
1685 }
1686
1687 private async persistEndpointSnapshot(
1688 connection: FirefoxWebSocketConnection,
1689 platform: string,
1690 summary: FirefoxBrowserHookSummary
1691 ): Promise<void> {
1692 const clientId = connection.getClientId();
1693
1694 if (clientId == null || summary.account == null) {
1695 return;
1696 }
1697
1698 await this.repository.upsertBrowserEndpointMetadata({
1699 account: summary.account,
1700 clientId,
1701 endpoints: summary.endpoints,
1702 lastVerifiedAt: toUnixSecondsMilliseconds(summary.lastVerifiedAt),
1703 platform,
1704 updatedAt: toUnixSecondsMilliseconds(summary.updatedAt) ?? this.now()
1705 });
1706 }
1707
1708 private async handleBrowserFinalMessage(
1709 connection: FirefoxWebSocketConnection,
1710 message: Record<string, unknown>
1711 ): Promise<void> {
1712 const platform = readFirstString(message, ["platform"]);
1713 const assistantMessageId = readFirstString(message, ["assistant_message_id", "assistantMessageId", "message_id"]);
1714 const rawText = typeof message.raw_text === "string"
1715 ? message.raw_text.trim()
1716 : (typeof message.rawText === "string" ? message.rawText.trim() : "");
1717
1718 if (platform == null) {
1719 this.sendError(connection, "invalid_message", "browser.final_message requires a platform field.");
1720 return;
1721 }
1722
1723 if (assistantMessageId == null) {
1724 this.sendError(
1725 connection,
1726 "invalid_message",
1727 "browser.final_message requires a non-empty assistant_message_id field."
1728 );
1729 return;
1730 }
1731
1732 if (!rawText) {
1733 this.sendError(connection, "invalid_message", "browser.final_message requires a non-empty raw_text field.");
1734 return;
1735 }
1736
1737 const finalMessage = {
1738 assistant_message_id: assistantMessageId,
1739 conversation_id: readFirstString(message, ["conversation_id", "conversationId"]),
1740 observed_at:
1741 readOptionalTimestampMilliseconds(message, ["observed_at", "observedAt"])
1742 ?? this.getNowMilliseconds(),
1743 organization_id: readFirstString(message, ["organization_id", "organizationId"]),
1744 page_title: readFirstString(message, ["page_title", "pageTitle"]),
1745 page_url: readFirstString(message, ["page_url", "pageUrl"]),
1746 platform,
1747 raw_text: rawText,
1748 shell_page: readOptionalBoolean(message, ["shell_page", "shellPage"]) === true,
1749 tab_id: readOptionalInteger(message, ["tab_id", "tabId"])
1750 };
1751 const route = buildDeliveryRouteSnapshot(message, {
1752 assistantMessageId: finalMessage.assistant_message_id,
1753 conversationId: finalMessage.conversation_id,
1754 observedAt: finalMessage.observed_at,
1755 platform: finalMessage.platform
1756 });
1757
1758 connection.addFinalMessage(finalMessage);
1759 this.deliveryBridge.observeRoute(route);
1760 let renewalObservation = await this.observeRenewalConversation(connection, finalMessage, route);
1761
1762 if (this.artifactStore != null && renewalObservation?.conversation != null) {
1763 renewalObservation = {
1764 ...renewalObservation,
1765 conversation: await recordAssistantMessageAutomationSignal({
1766 conversation: renewalObservation.conversation,
1767 observedAt: finalMessage.observed_at,
1768 rawText: finalMessage.raw_text,
1769 store: this.artifactStore
1770 })
1771 };
1772 }
1773
1774 await this.broadcastStateSnapshot("browser.final_message");
1775
1776 this.writeIngestLog({
1777 ts: new Date().toISOString(),
1778 event: "final_message_received",
1779 platform: finalMessage.platform,
1780 conversation_id: finalMessage.conversation_id ?? null,
1781 assistant_message_id: finalMessage.assistant_message_id,
1782 raw_text: finalMessage.raw_text,
1783 raw_text_length: finalMessage.raw_text.length
1784 });
1785
1786 if (this.instructionIngest == null) {
1787 return;
1788 }
1789
1790 const localConversationId = renewalObservation?.conversation.localConversationId ?? null;
1791 const hasInstructionBlocks = hasExtractedBaaInstructionBlocks(finalMessage.raw_text);
1792 let executionGateReason: "automation_busy" | null = null;
1793 let instructionLockAcquired = false;
1794
1795 if (
1796 hasInstructionBlocks
1797 && this.artifactStore != null
1798 && localConversationId != null
1799 ) {
1800 const lockedConversation = await this.artifactStore.tryBeginLocalConversationExecution({
1801 executionState: "instruction_running",
1802 localConversationId,
1803 updatedAt: finalMessage.observed_at
1804 });
1805
1806 if (lockedConversation == null) {
1807 executionGateReason = "automation_busy";
1808 } else {
1809 instructionLockAcquired = true;
1810 renewalObservation = {
1811 ...renewalObservation!,
1812 conversation: lockedConversation
1813 };
1814 }
1815 }
1816
1817 try {
1818 const ingestResult = await this.instructionIngest.ingestAssistantFinalMessage({
1819 assistantMessageId: finalMessage.assistant_message_id,
1820 conversationAutomationStatus: renewalObservation?.conversation.automationStatus ?? null,
1821 conversationId: finalMessage.conversation_id,
1822 executionGateReason,
1823 localConversationId,
1824 observedAt: finalMessage.observed_at,
1825 organizationId: finalMessage.organization_id,
1826 pageTitle: finalMessage.page_title,
1827 pageUrl: finalMessage.page_url,
1828 platform: finalMessage.platform,
1829 source: "browser.final_message",
1830 text: finalMessage.raw_text
1831 });
1832 await this.broadcastStateSnapshot("instruction_ingest");
1833
1834 this.writeIngestLog({
1835 ts: new Date().toISOString(),
1836 event: "ingest_completed",
1837 platform: finalMessage.platform,
1838 conversation_id: finalMessage.conversation_id ?? null,
1839 blocks_count: ingestResult.summary.block_count,
1840 executions_count: ingestResult.summary.execution_count,
1841 status: ingestResult.summary.status
1842 });
1843
1844 if (ingestResult.processResult == null) {
1845 return;
1846 }
1847
1848 try {
1849 await this.deliveryBridge.deliver({
1850 assistantMessageId: finalMessage.assistant_message_id,
1851 clientId: connection.getClientId(),
1852 connectionId: connection.getConnectionId(),
1853 conversationId: finalMessage.conversation_id,
1854 platform: finalMessage.platform,
1855 processResult: ingestResult.processResult,
1856 route
1857 });
1858 } catch {
1859 // delivery session state is already written back into browser snapshots
1860 }
1861 } finally {
1862 if (instructionLockAcquired && this.artifactStore != null && localConversationId != null) {
1863 await this.artifactStore.finishLocalConversationExecution({
1864 executionState: "instruction_running",
1865 localConversationId,
1866 updatedAt: this.getNowMilliseconds()
1867 });
1868 }
1869 }
1870 }
1871
1872 private writeIngestLog(entry: Record<string, unknown>): void {
1873 if (this.ingestLogDir == null) {
1874 return;
1875 }
1876
1877 try {
1878 const date = new Date().toISOString().slice(0, 10);
1879 const filePath = join(this.ingestLogDir, `${date}.jsonl`);
1880 appendFileSync(filePath, JSON.stringify(entry) + "\n");
1881 } catch (error) {
1882 console.error(`[baa-ingest-log] write failed: ${String(error)}`);
1883 }
1884 }
1885
1886 private async observeRenewalConversation(
1887 connection: FirefoxWebSocketConnection,
1888 finalMessage: {
1889 assistant_message_id: string;
1890 conversation_id: string | null;
1891 observed_at: number;
1892 page_title: string | null;
1893 page_url: string | null;
1894 platform: string;
1895 },
1896 route: BaaDeliveryRouteSnapshot | null
1897 ): Promise<ObserveRenewalConversationResult | null> {
1898 if (this.artifactStore == null) {
1899 return null;
1900 }
1901
1902 try {
1903 return observeRenewalConversation({
1904 assistantMessageId: finalMessage.assistant_message_id,
1905 clientId: connection.getClientId(),
1906 observedAt: finalMessage.observed_at,
1907 pageTitle: finalMessage.page_title,
1908 pageUrl: finalMessage.page_url,
1909 platform: finalMessage.platform,
1910 remoteConversationId: finalMessage.conversation_id,
1911 route,
1912 store: this.artifactStore
1913 });
1914 } catch (error) {
1915 console.error(`[baa-renewal] observe conversation failed: ${String(error)}`);
1916 return null;
1917 }
1918 }
1919
1920 private handlePluginDiagnosticLog(
1921 connection: FirefoxWebSocketConnection,
1922 message: Record<string, unknown>
1923 ): void {
1924 const level = normalizeDiagnosticLogLevel(message.level);
1925 const text = readFirstString(message, ["text"]);
1926
1927 if (level == null || text == null) {
1928 return;
1929 }
1930
1931 const rawTimestamp = readFirstString(message, ["ts", "timestamp"]);
1932 const parsedTimestamp = rawTimestamp == null ? Number.NaN : Date.parse(rawTimestamp);
1933 const timestamp = Number.isNaN(parsedTimestamp)
1934 ? new Date(this.getNextTimestampMilliseconds()).toISOString()
1935 : new Date(parsedTimestamp).toISOString();
1936
1937 this.writePluginDiagnosticLog({
1938 client_id: readFirstString(message, ["client_id", "clientId"]) ?? connection.getClientId(),
1939 level,
1940 text,
1941 ts: timestamp,
1942 type: "plugin_diagnostic_log"
1943 });
1944 }
1945
1946 private writePluginDiagnosticLog(entry: Record<string, unknown>): void {
1947 if (this.pluginDiagnosticLogDir == null) {
1948 return;
1949 }
1950
1951 try {
1952 const timestamp = readFirstString(entry, ["ts"]);
1953 const parsedTimestamp = timestamp == null ? Number.NaN : Date.parse(timestamp);
1954 const date = Number.isNaN(parsedTimestamp)
1955 ? new Date().toISOString().slice(0, 10)
1956 : new Date(parsedTimestamp).toISOString().slice(0, 10);
1957 const filePath = join(this.pluginDiagnosticLogDir, `${date}.jsonl`);
1958 appendFileSync(filePath, JSON.stringify(entry) + "\n");
1959 } catch (error) {
1960 console.error(`[baa-plugin-log] write failed: ${String(error)}`);
1961 }
1962 }
1963
1964 private handleApiResponse(
1965 connection: FirefoxWebSocketConnection,
1966 message: Record<string, unknown>
1967 ): void {
1968 const id = readFirstString(message, ["id", "requestId", "request_id"]);
1969
1970 if (id == null) {
1971 this.sendError(connection, "invalid_message", "api_response requires a non-empty id field.");
1972 return;
1973 }
1974
1975 const handled = this.bridgeService.handleApiResponse(connection.getConnectionId(), {
1976 body: message.body ?? null,
1977 error: readFirstString(message, ["error", "message"]),
1978 id,
1979 ok: message.ok !== false,
1980 status: typeof message.status === "number" && Number.isFinite(message.status)
1981 ? Math.round(message.status)
1982 : null
1983 });
1984
1985 if (!handled) {
1986 return;
1987 }
1988 }
1989
1990 private handleStreamOpen(
1991 connection: FirefoxWebSocketConnection,
1992 message: Record<string, unknown>
1993 ): void {
1994 const id = readFirstString(message, ["id", "requestId", "request_id"]);
1995
1996 if (id == null) {
1997 this.sendError(connection, "invalid_message", "stream_open requires a non-empty id field.");
1998 return;
1999 }
2000
2001 this.bridgeService.handleStreamOpen(connection.getConnectionId(), {
2002 id,
2003 meta: message.meta ?? null,
2004 status:
2005 typeof message.status === "number" && Number.isFinite(message.status)
2006 ? Math.round(message.status)
2007 : null,
2008 streamId: readFirstString(message, ["streamId", "stream_id"])
2009 });
2010 }
2011
2012 private handleStreamEvent(
2013 connection: FirefoxWebSocketConnection,
2014 message: Record<string, unknown>
2015 ): void {
2016 const id = readFirstString(message, ["id", "requestId", "request_id"]);
2017 const rawSeq = message.seq;
2018
2019 if (id == null) {
2020 this.sendError(connection, "invalid_message", "stream_event requires a non-empty id field.");
2021 return;
2022 }
2023
2024 if (typeof rawSeq !== "number" || !Number.isFinite(rawSeq) || rawSeq <= 0) {
2025 this.sendError(connection, "invalid_message", "stream_event requires a positive numeric seq field.");
2026 return;
2027 }
2028
2029 this.bridgeService.handleStreamEvent(connection.getConnectionId(), {
2030 data: message.data ?? null,
2031 event: readFirstString(message, ["event"]),
2032 id,
2033 raw: readFirstString(message, ["raw", "chunk"]),
2034 seq: Math.round(rawSeq),
2035 streamId: readFirstString(message, ["streamId", "stream_id"])
2036 });
2037 }
2038
2039 private handleStreamEnd(
2040 connection: FirefoxWebSocketConnection,
2041 message: Record<string, unknown>
2042 ): void {
2043 const id = readFirstString(message, ["id", "requestId", "request_id"]);
2044
2045 if (id == null) {
2046 this.sendError(connection, "invalid_message", "stream_end requires a non-empty id field.");
2047 return;
2048 }
2049
2050 this.bridgeService.handleStreamEnd(connection.getConnectionId(), {
2051 id,
2052 status:
2053 typeof message.status === "number" && Number.isFinite(message.status)
2054 ? Math.round(message.status)
2055 : null,
2056 streamId: readFirstString(message, ["streamId", "stream_id"])
2057 });
2058 }
2059
2060 private handleStreamError(
2061 connection: FirefoxWebSocketConnection,
2062 message: Record<string, unknown>
2063 ): void {
2064 const id = readFirstString(message, ["id", "requestId", "request_id"]);
2065
2066 if (id == null) {
2067 this.sendError(connection, "invalid_message", "stream_error requires a non-empty id field.");
2068 return;
2069 }
2070
2071 this.bridgeService.handleStreamError(connection.getConnectionId(), {
2072 code: readFirstString(message, ["code", "error"]),
2073 id,
2074 message: readFirstString(message, ["message"]),
2075 status:
2076 typeof message.status === "number" && Number.isFinite(message.status)
2077 ? Math.round(message.status)
2078 : null,
2079 streamId: readFirstString(message, ["streamId", "stream_id"])
2080 });
2081 }
2082
2083 private async refreshBrowserLoginStateStatuses(): Promise<void> {
2084 await this.repository.markBrowserLoginStatesLost(
2085 this.now() - Math.ceil(FIREFOX_WS_LOGIN_STATE_LOST_AFTER_MS / 1000)
2086 );
2087 await this.repository.markBrowserLoginStatesStale(
2088 this.now() - Math.ceil(FIREFOX_WS_LOGIN_STATE_STALE_AFTER_MS / 1000)
2089 );
2090
2091 const nowMs = this.getNowMilliseconds();
2092
2093 for (const connection of this.connectionsByClientId.values()) {
2094 const agedStatus = getAgedLoginStateStatus(connection.session.lastMessageAt, nowMs);
2095
2096 if (agedStatus == null) {
2097 continue;
2098 }
2099
2100 await this.updateClientLoginStateStatus(connection.getClientId(), agedStatus);
2101 }
2102 }
2103
2104 private async markClientLoginStatesStale(clientId: string | null): Promise<void> {
2105 await this.updateClientLoginStateStatus(clientId, "stale");
2106 }
2107
2108 private async updateClientLoginStateStatus(
2109 clientId: string | null,
2110 nextStatus: BrowserBridgeLoginStatus
2111 ): Promise<void> {
2112 if (clientId == null) {
2113 return;
2114 }
2115
2116 const records = await this.repository.listBrowserLoginStates({
2117 clientId
2118 });
2119
2120 if (records.length === 0) {
2121 return;
2122 }
2123
2124 const connection = this.connectionsByClientId.get(clientId);
2125
2126 if (connection != null) {
2127 for (const [platform, summary] of connection.session.credentials.entries()) {
2128 const currentStatus = summary.freshness ?? "fresh";
2129
2130 if (getBrowserLoginStateRank(nextStatus) > getBrowserLoginStateRank(currentStatus)) {
2131 connection.updateCredential(platform, {
2132 ...summary,
2133 freshness: nextStatus
2134 });
2135 }
2136 }
2137 }
2138
2139 for (const record of records) {
2140 if (getBrowserLoginStateRank(nextStatus) <= getBrowserLoginStateRank(record.status)) {
2141 continue;
2142 }
2143
2144 await this.repository.upsertBrowserLoginState({
2145 ...record,
2146 status: nextStatus
2147 });
2148 }
2149 }
2150
2151 private async buildStateSnapshot(): Promise<Record<string, unknown>> {
2152 await this.refreshConversationAutomationSnapshot();
2153 const runtime = this.snapshotLoader();
2154
2155 return {
2156 browser: this.buildBrowserStateSnapshot(),
2157 server: {
2158 host: runtime.daemon.host,
2159 identity: runtime.identity,
2160 lease_state: runtime.daemon.leaseState,
2161 local_api_base: runtime.controlApi.localApiBase ?? null,
2162 node_id: runtime.daemon.nodeId,
2163 role: runtime.daemon.role,
2164 scheduler_enabled: runtime.daemon.schedulerEnabled,
2165 started: runtime.runtime.started,
2166 started_at: toUnixMilliseconds(runtime.runtime.startedAt),
2167 ws_path: BROWSER_WS_PATH,
2168 ws_url: this.getUrl()
2169 },
2170 system: await buildSystemStateData(this.repository),
2171 version: BROWSER_WS_PROTOCOL_VERSION
2172 };
2173 }
2174
2175 private buildBrowserStateSnapshot(): BrowserBridgeStateSnapshot {
2176 const clients = [...this.connections]
2177 .map((connection) => connection.describe())
2178 .sort((left, right) =>
2179 String(left.client_id ?? "").localeCompare(String(right.client_id ?? ""))
2180 );
2181 const activeClient = this.getActiveClient();
2182
2183 return {
2184 active_client_id: activeClient?.clientId ?? null,
2185 active_connection_id: activeClient?.connectionId ?? null,
2186 automation_conversations: this.automationConversationsSnapshot.map((entry) => ({
2187 ...entry,
2188 active_link:
2189 entry.active_link == null
2190 ? null
2191 : {
2192 ...entry.active_link
2193 }
2194 })),
2195 client_count: clients.length,
2196 clients,
2197 delivery: this.deliveryBridge.getSnapshot(),
2198 instruction_ingest: this.instructionIngest?.getSnapshot() ?? {
2199 last_execute: null,
2200 last_ingest: null,
2201 recent_executes: [],
2202 recent_ingests: []
2203 },
2204 ws_path: BROWSER_WS_PATH,
2205 ws_url: this.getUrl()
2206 };
2207 }
2208
2209 private async refreshConversationAutomationSnapshot(): Promise<void> {
2210 this.automationConversationsSnapshot = await this.loadConversationAutomationSnapshot();
2211 }
2212
2213 private async loadConversationAutomationSnapshot(): Promise<BrowserBridgeConversationAutomationSnapshot[]> {
2214 if (this.artifactStore == null) {
2215 return [];
2216 }
2217
2218 const activeLinks = await this.artifactStore.listConversationLinks({
2219 isActive: true,
2220 limit: 200
2221 });
2222
2223 if (activeLinks.length === 0) {
2224 return [];
2225 }
2226
2227 const conversations = await Promise.all(
2228 [...new Set(activeLinks.map((entry) => entry.localConversationId))]
2229 .map(async (localConversationId) => [
2230 localConversationId,
2231 await this.artifactStore!.getLocalConversation(localConversationId)
2232 ] as const)
2233 );
2234 const conversationsById = new Map(
2235 conversations.filter(([, entry]) => entry != null) as Array<
2236 readonly [string, NonNullable<Awaited<ReturnType<ArtifactStore["getLocalConversation"]>>>]
2237 >
2238 );
2239
2240 const snapshots: BrowserBridgeConversationAutomationSnapshot[] = [];
2241
2242 for (const link of activeLinks) {
2243 const conversation = conversationsById.get(link.localConversationId);
2244
2245 if (conversation == null) {
2246 continue;
2247 }
2248
2249 snapshots.push({
2250 active_link: {
2251 client_id: link.clientId ?? null,
2252 link_id: link.linkId,
2253 local_conversation_id: link.localConversationId,
2254 page_title: link.pageTitle ?? null,
2255 page_url: link.pageUrl ?? null,
2256 remote_conversation_id: link.remoteConversationId ?? null,
2257 route_path: link.routePath ?? null,
2258 route_pattern: link.routePattern ?? null,
2259 target_id: link.targetId ?? null,
2260 target_kind: link.targetKind ?? null,
2261 updated_at: link.updatedAt
2262 },
2263 automation_status: conversation.automationStatus,
2264 execution_state: conversation.executionState,
2265 last_error: conversation.lastError ?? null,
2266 last_non_paused_automation_status: conversation.lastNonPausedAutomationStatus,
2267 local_conversation_id: conversation.localConversationId,
2268 pause_reason: conversation.pauseReason ?? null,
2269 paused_at: conversation.pausedAt ?? null,
2270 platform: conversation.platform,
2271 remote_conversation_id: link.remoteConversationId ?? null,
2272 updated_at: conversation.updatedAt
2273 });
2274 }
2275
2276 return snapshots.sort((left, right) => {
2277 const leftKey = [
2278 left.platform,
2279 left.remote_conversation_id ?? "",
2280 left.local_conversation_id,
2281 left.active_link?.link_id ?? ""
2282 ].join("\u0000");
2283 const rightKey = [
2284 right.platform,
2285 right.remote_conversation_id ?? "",
2286 right.local_conversation_id,
2287 right.active_link?.link_id ?? ""
2288 ].join("\u0000");
2289
2290 return leftKey.localeCompare(rightKey);
2291 });
2292 }
2293
2294 private async sendStateSnapshotTo(
2295 connection: FirefoxWebSocketConnection,
2296 reason: string
2297 ): Promise<void> {
2298 const snapshot = await this.buildStateSnapshot();
2299 connection.sendJson({
2300 type: "state_snapshot",
2301 reason,
2302 snapshot
2303 });
2304 }
2305
2306 private async broadcastStateSnapshot(
2307 reason: string,
2308 options: {
2309 force?: boolean;
2310 } = {}
2311 ): Promise<void> {
2312 if (this.connections.size === 0) {
2313 return;
2314 }
2315
2316 const task = this.broadcastQueue.then(async () => {
2317 const snapshot = await this.buildStateSnapshot();
2318 const signature = JSON.stringify(snapshot);
2319
2320 if (!options.force && this.lastSnapshotSignature === signature) {
2321 return;
2322 }
2323
2324 this.lastSnapshotSignature = signature;
2325 const payload = {
2326 type: "state_snapshot",
2327 reason,
2328 snapshot
2329 };
2330
2331 for (const connection of this.connections) {
2332 connection.sendJson(payload);
2333 }
2334 });
2335
2336 this.broadcastQueue = task.catch(() => {});
2337 await task;
2338 }
2339
2340 private sendError(
2341 connection: FirefoxWebSocketConnection,
2342 code: string,
2343 message: string
2344 ): void {
2345 connection.sendJson({
2346 type: "error",
2347 code,
2348 message
2349 });
2350 }
2351
2352 private getActiveClient(): FirefoxBridgeRegisteredClient | null {
2353 const clients = [...this.connectionsByClientId.values()];
2354
2355 if (clients.length === 0) {
2356 return null;
2357 }
2358
2359 clients.sort((left, right) => {
2360 if (left.session.lastMessageAt !== right.session.lastMessageAt) {
2361 return right.session.lastMessageAt - left.session.lastMessageAt;
2362 }
2363
2364 return right.session.connectedAt - left.session.connectedAt;
2365 });
2366
2367 return this.toRegisteredClient(clients[0] ?? null);
2368 }
2369
2370 private getClientById(clientId: string): FirefoxBridgeRegisteredClient | null {
2371 return this.toRegisteredClient(this.connectionsByClientId.get(clientId) ?? null);
2372 }
2373
2374 private toRegisteredClient(
2375 connection: FirefoxWebSocketConnection | null
2376 ): FirefoxBridgeRegisteredClient | null {
2377 if (connection == null) {
2378 return null;
2379 }
2380
2381 const clientId = connection.getClientId();
2382
2383 if (clientId == null) {
2384 return null;
2385 }
2386
2387 return {
2388 clientId,
2389 connectedAt: connection.session.connectedAt,
2390 connectionId: connection.getConnectionId(),
2391 lastMessageAt: connection.session.lastMessageAt,
2392 sendJson: (payload) => connection.sendJson(payload)
2393 };
2394 }
2395}
2396
2397function mapActionToMode(action: FirefoxWsAction): "paused" | "running" | "draining" {
2398 switch (action) {
2399 case "pause":
2400 return "paused";
2401 case "resume":
2402 return "running";
2403 case "drain":
2404 return "draining";
2405 }
2406}