baa-conductor


baa-conductor / apps / conductor-daemon / src
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}