baa-conductor

git clone 

commit
667fc6a
parent
8a3a964
author
im_wower
date
2026-03-23 23:25:38 +0800 CST
feat(conductor-daemon): add firefox ws command broker
6 files changed,  +1043, -8
A apps/conductor-daemon/src/firefox-bridge.ts
+511, -0
  1@@ -0,0 +1,511 @@
  2+import { randomUUID } from "node:crypto";
  3+
  4+const DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS = 15_000;
  5+
  6+type TimeoutHandle = ReturnType<typeof globalThis.setTimeout>;
  7+
  8+export type FirefoxBridgeOutboundCommandType =
  9+  | "open_tab"
 10+  | "request_credentials"
 11+  | "reload"
 12+  | "api_request";
 13+
 14+export type FirefoxBridgeErrorCode =
 15+  | "client_not_found"
 16+  | "duplicate_request_id"
 17+  | "no_active_client"
 18+  | "request_timeout"
 19+  | "send_failed"
 20+  | "client_disconnected"
 21+  | "client_replaced"
 22+  | "service_stopped";
 23+
 24+export interface FirefoxBridgeRegisteredClient {
 25+  clientId: string;
 26+  connectedAt: number;
 27+  connectionId: string;
 28+  lastMessageAt: number;
 29+  sendJson(payload: Record<string, unknown>): boolean;
 30+}
 31+
 32+export interface FirefoxBridgeCommandTarget {
 33+  clientId?: string | null;
 34+}
 35+
 36+export interface FirefoxBridgeDispatchReceipt {
 37+  clientId: string;
 38+  connectionId: string;
 39+  dispatchedAt: number;
 40+  type: FirefoxBridgeOutboundCommandType;
 41+}
 42+
 43+export interface FirefoxOpenTabCommandInput extends FirefoxBridgeCommandTarget {
 44+  platform?: string | null;
 45+}
 46+
 47+export interface FirefoxRequestCredentialsCommandInput extends FirefoxBridgeCommandTarget {
 48+  platform?: string | null;
 49+  reason?: string | null;
 50+}
 51+
 52+export interface FirefoxReloadCommandInput extends FirefoxBridgeCommandTarget {
 53+  reason?: string | null;
 54+}
 55+
 56+export interface FirefoxApiRequestCommandInput extends FirefoxBridgeCommandTarget {
 57+  body?: unknown;
 58+  headers?: Record<string, string> | null;
 59+  id?: string | null;
 60+  method?: string | null;
 61+  path: string;
 62+  platform: string;
 63+  timeoutMs?: number | null;
 64+}
 65+
 66+export interface FirefoxApiResponsePayload {
 67+  body: unknown;
 68+  error: string | null;
 69+  id: string;
 70+  ok: boolean;
 71+  status: number | null;
 72+}
 73+
 74+export interface FirefoxBridgeApiResponse extends FirefoxApiResponsePayload {
 75+  clientId: string;
 76+  connectionId: string;
 77+  respondedAt: number;
 78+}
 79+
 80+export interface FirefoxBridgeConnectionClosedEvent {
 81+  clientId: string | null;
 82+  code?: number | null;
 83+  connectionId: string;
 84+  reason?: string | null;
 85+}
 86+
 87+interface FirefoxPendingApiRequest {
 88+  clientId: string;
 89+  connectionId: string;
 90+  reject: (error: FirefoxBridgeError) => void;
 91+  requestId: string;
 92+  resolve: (response: FirefoxBridgeApiResponse) => void;
 93+  timer: TimeoutHandle;
 94+}
 95+
 96+interface FirefoxCommandBrokerOptions {
 97+  clearTimeoutImpl?: (handle: TimeoutHandle) => void;
 98+  now?: () => number;
 99+  resolveActiveClient: () => FirefoxBridgeRegisteredClient | null;
100+  resolveClientById: (clientId: string) => FirefoxBridgeRegisteredClient | null;
101+  setTimeoutImpl?: (handler: () => void, timeoutMs: number) => TimeoutHandle;
102+}
103+
104+function normalizeOptionalString(value: unknown): string | null {
105+  if (typeof value !== "string") {
106+    return null;
107+  }
108+
109+  const normalized = value.trim();
110+  return normalized === "" ? null : normalized;
111+}
112+
113+function normalizeHeaderRecord(
114+  headers: Record<string, string> | null | undefined
115+): Record<string, string> | undefined {
116+  if (headers == null) {
117+    return undefined;
118+  }
119+
120+  const normalized: Record<string, string> = {};
121+
122+  for (const [name, value] of Object.entries(headers)) {
123+    const normalizedName = normalizeOptionalString(name);
124+
125+    if (normalizedName == null || typeof value !== "string") {
126+      continue;
127+    }
128+
129+    normalized[normalizedName] = value;
130+  }
131+
132+  return Object.keys(normalized).length > 0 ? normalized : undefined;
133+}
134+
135+function normalizeTimeoutMs(value: number | null | undefined): number {
136+  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
137+    return DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS;
138+  }
139+
140+  return Math.round(value);
141+}
142+
143+function normalizeStatus(value: number | null | undefined): number | null {
144+  if (typeof value !== "number" || !Number.isFinite(value)) {
145+    return null;
146+  }
147+
148+  return Math.round(value);
149+}
150+
151+function compactRecord(input: Record<string, unknown>): Record<string, unknown> {
152+  const output: Record<string, unknown> = {};
153+
154+  for (const [key, value] of Object.entries(input)) {
155+    if (value !== undefined) {
156+      output[key] = value;
157+    }
158+  }
159+
160+  return output;
161+}
162+
163+export class FirefoxBridgeError extends Error {
164+  readonly clientId: string | null;
165+  readonly code: FirefoxBridgeErrorCode;
166+  readonly connectionId: string | null;
167+  readonly requestId: string | null;
168+
169+  constructor(
170+    code: FirefoxBridgeErrorCode,
171+    message: string,
172+    options: {
173+      clientId?: string | null;
174+      connectionId?: string | null;
175+      requestId?: string | null;
176+    } = {}
177+  ) {
178+    super(message);
179+    this.name = "FirefoxBridgeError";
180+    this.clientId = options.clientId ?? null;
181+    this.code = code;
182+    this.connectionId = options.connectionId ?? null;
183+    this.requestId = options.requestId ?? null;
184+  }
185+}
186+
187+export class FirefoxCommandBroker {
188+  private readonly clearTimeoutImpl: (handle: TimeoutHandle) => void;
189+  private readonly now: () => number;
190+  private readonly pendingApiRequests = new Map<string, FirefoxPendingApiRequest>();
191+  private readonly resolveActiveClient: () => FirefoxBridgeRegisteredClient | null;
192+  private readonly resolveClientById: (clientId: string) => FirefoxBridgeRegisteredClient | null;
193+  private readonly setTimeoutImpl: (handler: () => void, timeoutMs: number) => TimeoutHandle;
194+
195+  constructor(options: FirefoxCommandBrokerOptions) {
196+    this.clearTimeoutImpl = options.clearTimeoutImpl ?? ((handle) => globalThis.clearTimeout(handle));
197+    this.now = options.now ?? (() => Date.now());
198+    this.resolveActiveClient = options.resolveActiveClient;
199+    this.resolveClientById = options.resolveClientById;
200+    this.setTimeoutImpl = options.setTimeoutImpl ?? ((handler, timeoutMs) => globalThis.setTimeout(handler, timeoutMs));
201+  }
202+
203+  dispatch(
204+    type: Exclude<FirefoxBridgeOutboundCommandType, "api_request">,
205+    payload: Record<string, unknown>,
206+    target: FirefoxBridgeCommandTarget = {}
207+  ): FirefoxBridgeDispatchReceipt {
208+    const client = this.selectClient(target);
209+    const dispatchedAt = this.now();
210+    const envelope = compactRecord({
211+      ...payload,
212+      type
213+    });
214+
215+    if (!client.sendJson(envelope)) {
216+      throw new FirefoxBridgeError(
217+        "send_failed",
218+        `Failed to send ${type} to Firefox client "${client.clientId}".`,
219+        {
220+          clientId: client.clientId,
221+          connectionId: client.connectionId
222+        }
223+      );
224+    }
225+
226+    return {
227+      clientId: client.clientId,
228+      connectionId: client.connectionId,
229+      dispatchedAt,
230+      type
231+    };
232+  }
233+
234+  sendApiRequest(
235+    payload: Record<string, unknown>,
236+    options: FirefoxBridgeCommandTarget & {
237+      requestId: string;
238+      timeoutMs?: number | null;
239+    }
240+  ): Promise<FirefoxBridgeApiResponse> {
241+    if (this.pendingApiRequests.has(options.requestId)) {
242+      throw new FirefoxBridgeError(
243+        "duplicate_request_id",
244+        `Firefox bridge request id "${options.requestId}" is already in flight.`,
245+        {
246+          requestId: options.requestId
247+        }
248+      );
249+    }
250+
251+    const client = this.selectClient(options);
252+    const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
253+    const envelope = compactRecord({
254+      ...payload,
255+      id: options.requestId,
256+      type: "api_request"
257+    });
258+
259+    return new Promise<FirefoxBridgeApiResponse>((resolve, reject) => {
260+      const timer = this.setTimeoutImpl(() => {
261+        this.pendingApiRequests.delete(options.requestId);
262+        reject(
263+          new FirefoxBridgeError(
264+            "request_timeout",
265+            `Firefox client "${client.clientId}" did not respond to api_request "${options.requestId}" within ${timeoutMs}ms.`,
266+            {
267+              clientId: client.clientId,
268+              connectionId: client.connectionId,
269+              requestId: options.requestId
270+            }
271+          )
272+        );
273+      }, timeoutMs);
274+
275+      this.pendingApiRequests.set(options.requestId, {
276+        clientId: client.clientId,
277+        connectionId: client.connectionId,
278+        reject,
279+        requestId: options.requestId,
280+        resolve,
281+        timer
282+      });
283+
284+      if (!client.sendJson(envelope)) {
285+        this.clearPendingRequest(options.requestId);
286+        reject(
287+          new FirefoxBridgeError(
288+            "send_failed",
289+            `Failed to send api_request "${options.requestId}" to Firefox client "${client.clientId}".`,
290+            {
291+              clientId: client.clientId,
292+              connectionId: client.connectionId,
293+              requestId: options.requestId
294+            }
295+          )
296+        );
297+      }
298+    });
299+  }
300+
301+  handleApiResponse(
302+    connectionId: string,
303+    payload: FirefoxApiResponsePayload
304+  ): boolean {
305+    const pending = this.pendingApiRequests.get(payload.id);
306+
307+    if (pending == null || pending.connectionId !== connectionId) {
308+      return false;
309+    }
310+
311+    this.clearPendingRequest(payload.id);
312+    pending.resolve({
313+      body: payload.body,
314+      clientId: pending.clientId,
315+      connectionId,
316+      error: payload.error,
317+      id: payload.id,
318+      ok: payload.ok,
319+      respondedAt: this.now(),
320+      status: payload.status
321+    });
322+    return true;
323+  }
324+
325+  handleConnectionClosed(event: FirefoxBridgeConnectionClosedEvent): void {
326+    const requestIds = [...this.pendingApiRequests.values()]
327+      .filter((entry) => entry.connectionId === event.connectionId)
328+      .map((entry) => entry.requestId);
329+
330+    if (requestIds.length === 0) {
331+      return;
332+    }
333+
334+    const errorCode: FirefoxBridgeErrorCode =
335+      event.code === 4001 ? "client_replaced" : "client_disconnected";
336+    const clientLabel = event.clientId ?? "unknown";
337+    const reasonSuffix =
338+      normalizeOptionalString(event.reason) == null ? "" : ` (${normalizeOptionalString(event.reason)})`;
339+
340+    for (const requestId of requestIds) {
341+      const pending = this.clearPendingRequest(requestId);
342+
343+      if (pending == null) {
344+        continue;
345+      }
346+
347+      pending.reject(
348+        new FirefoxBridgeError(
349+          errorCode,
350+          errorCode === "client_replaced"
351+            ? `Firefox client "${clientLabel}" was replaced before api_request "${requestId}" completed${reasonSuffix}.`
352+            : `Firefox client "${clientLabel}" disconnected before api_request "${requestId}" completed${reasonSuffix}.`,
353+          {
354+            clientId: pending.clientId,
355+            connectionId: pending.connectionId,
356+            requestId
357+          }
358+        )
359+      );
360+    }
361+  }
362+
363+  stop(): void {
364+    const requestIds = [...this.pendingApiRequests.keys()];
365+
366+    for (const requestId of requestIds) {
367+      const pending = this.clearPendingRequest(requestId);
368+
369+      if (pending == null) {
370+        continue;
371+      }
372+
373+      pending.reject(
374+        new FirefoxBridgeError(
375+          "service_stopped",
376+          `Firefox bridge stopped before api_request "${requestId}" completed.`,
377+          {
378+            clientId: pending.clientId,
379+            connectionId: pending.connectionId,
380+            requestId
381+          }
382+        )
383+      );
384+    }
385+  }
386+
387+  private clearPendingRequest(requestId: string): FirefoxPendingApiRequest | null {
388+    const pending = this.pendingApiRequests.get(requestId);
389+
390+    if (pending == null) {
391+      return null;
392+    }
393+
394+    this.pendingApiRequests.delete(requestId);
395+    this.clearTimeoutImpl(pending.timer);
396+    return pending;
397+  }
398+
399+  private selectClient(target: FirefoxBridgeCommandTarget): FirefoxBridgeRegisteredClient {
400+    const normalizedClientId = normalizeOptionalString(target.clientId);
401+    const client =
402+      normalizedClientId == null
403+        ? this.resolveActiveClient()
404+        : this.resolveClientById(normalizedClientId);
405+
406+    if (client != null) {
407+      return client;
408+    }
409+
410+    if (normalizedClientId == null) {
411+      throw new FirefoxBridgeError(
412+        "no_active_client",
413+        "No active Firefox bridge client is connected."
414+      );
415+    }
416+
417+    throw new FirefoxBridgeError(
418+      "client_not_found",
419+      `Firefox bridge client "${normalizedClientId}" is not connected.`,
420+      {
421+        clientId: normalizedClientId
422+      }
423+    );
424+  }
425+}
426+
427+export class FirefoxBridgeService {
428+  constructor(private readonly broker: FirefoxCommandBroker) {}
429+
430+  openTab(input: FirefoxOpenTabCommandInput = {}): FirefoxBridgeDispatchReceipt {
431+    const platform = normalizeOptionalString(input.platform);
432+
433+    return this.broker.dispatch(
434+      "open_tab",
435+      compactRecord({
436+        platform: platform ?? undefined
437+      }),
438+      input
439+    );
440+  }
441+
442+  requestCredentials(
443+    input: FirefoxRequestCredentialsCommandInput = {}
444+  ): FirefoxBridgeDispatchReceipt {
445+    return this.broker.dispatch(
446+      "request_credentials",
447+      compactRecord({
448+        platform: normalizeOptionalString(input.platform) ?? undefined,
449+        reason: normalizeOptionalString(input.reason) ?? undefined
450+      }),
451+      input
452+    );
453+  }
454+
455+  reload(input: FirefoxReloadCommandInput = {}): FirefoxBridgeDispatchReceipt {
456+    return this.broker.dispatch(
457+      "reload",
458+      compactRecord({
459+        reason: normalizeOptionalString(input.reason) ?? undefined
460+      }),
461+      input
462+    );
463+  }
464+
465+  async apiRequest(input: FirefoxApiRequestCommandInput): Promise<FirefoxBridgeApiResponse> {
466+    const platform = normalizeOptionalString(input.platform);
467+    const path = normalizeOptionalString(input.path);
468+
469+    if (platform == null) {
470+      throw new Error("Firefox bridge api_request requires a non-empty platform.");
471+    }
472+
473+    if (path == null) {
474+      throw new Error("Firefox bridge api_request requires a non-empty path.");
475+    }
476+
477+    const requestId = normalizeOptionalString(input.id) ?? randomUUID();
478+
479+    return await this.broker.sendApiRequest(
480+      compactRecord({
481+        body: input.body ?? null,
482+        headers: normalizeHeaderRecord(input.headers),
483+        method: normalizeOptionalString(input.method)?.toUpperCase() ?? "GET",
484+        path,
485+        platform
486+      }),
487+      {
488+        clientId: input.clientId,
489+        requestId,
490+        timeoutMs: input.timeoutMs
491+      }
492+    );
493+  }
494+
495+  handleApiResponse(connectionId: string, payload: FirefoxApiResponsePayload): boolean {
496+    return this.broker.handleApiResponse(connectionId, {
497+      body: payload.body,
498+      error: payload.error,
499+      id: payload.id,
500+      ok: payload.ok,
501+      status: normalizeStatus(payload.status)
502+    });
503+  }
504+
505+  handleConnectionClosed(event: FirefoxBridgeConnectionClosedEvent): void {
506+    this.broker.handleConnectionClosed(event);
507+  }
508+
509+  stop(): void {
510+    this.broker.stop();
511+  }
512+}
M apps/conductor-daemon/src/firefox-ws.ts
+123, -6
  1@@ -3,6 +3,11 @@ import type { IncomingMessage } from "node:http";
  2 import type { Socket } from "node:net";
  3 import type { ControlPlaneRepository } from "../../../packages/db/dist/index.js";
  4 
  5+import {
  6+  FirefoxBridgeService,
  7+  FirefoxCommandBroker,
  8+  type FirefoxBridgeRegisteredClient
  9+} from "./firefox-bridge.js";
 10 import { buildSystemStateData, setAutomationMode } from "./local-api.js";
 11 import type { ConductorRuntimeSnapshot } from "./index.js";
 12 
 13@@ -195,6 +200,8 @@ class FirefoxWebSocketConnection {
 14   readonly session: FirefoxBrowserSession;
 15   private closed = false;
 16   private buffer = Buffer.alloc(0);
 17+  private closeCode: number | null = null;
 18+  private closeReason: string | null = null;
 19 
 20   constructor(
 21     private readonly socket: Socket,
 22@@ -239,6 +246,17 @@ class FirefoxWebSocketConnection {
 23     return this.session.clientId;
 24   }
 25 
 26+  getConnectionId(): string {
 27+    return this.session.id;
 28+  }
 29+
 30+  getCloseInfo(): { code: number | null; reason: string | null } {
 31+    return {
 32+      code: this.closeCode,
 33+      reason: this.closeReason
 34+    };
 35+  }
 36+
 37   setClientMetadata(metadata: {
 38     clientId: string;
 39     nodeCategory: string | null;
 40@@ -314,6 +332,8 @@ class FirefoxWebSocketConnection {
 41     }
 42 
 43     this.closed = true;
 44+    this.closeCode = code;
 45+    this.closeReason = reason;
 46 
 47     try {
 48       this.socket.write(buildFrame(0x8, buildClosePayload(code, reason)));
 49@@ -461,6 +481,7 @@ class FirefoxWebSocketConnection {
 50 
 51 export class ConductorFirefoxWebSocketServer {
 52   private readonly baseUrlLoader: () => string;
 53+  private readonly bridgeService: FirefoxBridgeService;
 54   private readonly now: () => number;
 55   private readonly repository: ControlPlaneRepository;
 56   private readonly snapshotLoader: () => ConductorRuntimeSnapshot;
 57@@ -475,12 +496,22 @@ export class ConductorFirefoxWebSocketServer {
 58     this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
 59     this.repository = options.repository;
 60     this.snapshotLoader = options.snapshotLoader;
 61+    const commandBroker = new FirefoxCommandBroker({
 62+      now: () => Date.now(),
 63+      resolveActiveClient: () => this.getActiveClient(),
 64+      resolveClientById: (clientId) => this.getClientById(clientId)
 65+    });
 66+    this.bridgeService = new FirefoxBridgeService(commandBroker);
 67   }
 68 
 69   getUrl(): string | null {
 70     return buildFirefoxWebSocketUrl(this.baseUrlLoader());
 71   }
 72 
 73+  getBridgeService(): FirefoxBridgeService {
 74+    return this.bridgeService;
 75+  }
 76+
 77   start(): void {
 78     if (this.pollTimer != null) {
 79       return;
 80@@ -497,6 +528,8 @@ export class ConductorFirefoxWebSocketServer {
 81       this.pollTimer = null;
 82     }
 83 
 84+    this.bridgeService.stop();
 85+
 86     for (const connection of [...this.connections]) {
 87       connection.close(1001, "server shutdown");
 88     }
 89@@ -555,11 +588,19 @@ export class ConductorFirefoxWebSocketServer {
 90     this.connections.delete(connection);
 91 
 92     const clientId = connection.getClientId();
 93+    const closeInfo = connection.getCloseInfo();
 94 
 95     if (clientId != null && this.connectionsByClientId.get(clientId) === connection) {
 96       this.connectionsByClientId.delete(clientId);
 97     }
 98 
 99+    this.bridgeService.handleConnectionClosed({
100+      clientId,
101+      code: closeInfo.code,
102+      connectionId: connection.getConnectionId(),
103+      reason: closeInfo.reason
104+    });
105+
106     void this.broadcastStateSnapshot("disconnect", {
107       force: true
108     });
109@@ -615,13 +656,15 @@ export class ConductorFirefoxWebSocketServer {
110       case "client_log":
111         return;
112       case "api_request":
113-      case "api_response":
114         this.sendError(
115           connection,
116-          "not_implemented",
117-          "API proxy over WS is not implemented on conductor-daemon; only control snapshots and action requests are supported."
118+          "unsupported_message_type",
119+          "api_request is a server-initiated Firefox bridge command and cannot be sent by the client."
120         );
121         return;
122+      case "api_response":
123+        this.handleApiResponse(connection, message);
124+        return;
125       default:
126         this.sendError(connection, "unsupported_message_type", `Unsupported WS message type: ${type}.`);
127     }
128@@ -661,20 +704,24 @@ export class ConductorFirefoxWebSocketServer {
129           "action_request",
130           "credentials",
131           "api_endpoints",
132-          "client_log"
133+          "client_log",
134+          "api_response"
135         ],
136         outbound: [
137           "hello_ack",
138           "state_snapshot",
139           "action_result",
140           "request_credentials",
141+          "open_tab",
142+          "reload",
143+          "api_request",
144           "error"
145         ]
146       }
147     });
148     await this.sendStateSnapshotTo(connection, "hello");
149-    connection.sendJson({
150-      type: "request_credentials",
151+    this.bridgeService.requestCredentials({
152+      clientId,
153       reason: "hello"
154     });
155     await this.broadcastStateSnapshot("client_hello", {
156@@ -779,6 +826,32 @@ export class ConductorFirefoxWebSocketServer {
157     await this.broadcastStateSnapshot("api_endpoints");
158   }
159 
160+  private handleApiResponse(
161+    connection: FirefoxWebSocketConnection,
162+    message: Record<string, unknown>
163+  ): void {
164+    const id = readFirstString(message, ["id", "requestId", "request_id"]);
165+
166+    if (id == null) {
167+      this.sendError(connection, "invalid_message", "api_response requires a non-empty id field.");
168+      return;
169+    }
170+
171+    const handled = this.bridgeService.handleApiResponse(connection.getConnectionId(), {
172+      body: message.body ?? null,
173+      error: readFirstString(message, ["error", "message"]),
174+      id,
175+      ok: message.ok !== false,
176+      status: typeof message.status === "number" && Number.isFinite(message.status)
177+        ? Math.round(message.status)
178+        : null
179+    });
180+
181+    if (!handled) {
182+      return;
183+    }
184+  }
185+
186   private async buildStateSnapshot(): Promise<Record<string, unknown>> {
187     const runtime = this.snapshotLoader();
188     const clients = [...this.connections]
189@@ -867,6 +940,50 @@ export class ConductorFirefoxWebSocketServer {
190       message
191     });
192   }
193+
194+  private getActiveClient(): FirefoxBridgeRegisteredClient | null {
195+    const clients = [...this.connectionsByClientId.values()];
196+
197+    if (clients.length === 0) {
198+      return null;
199+    }
200+
201+    clients.sort((left, right) => {
202+      if (left.session.lastMessageAt !== right.session.lastMessageAt) {
203+        return right.session.lastMessageAt - left.session.lastMessageAt;
204+      }
205+
206+      return right.session.connectedAt - left.session.connectedAt;
207+    });
208+
209+    return this.toRegisteredClient(clients[0] ?? null);
210+  }
211+
212+  private getClientById(clientId: string): FirefoxBridgeRegisteredClient | null {
213+    return this.toRegisteredClient(this.connectionsByClientId.get(clientId) ?? null);
214+  }
215+
216+  private toRegisteredClient(
217+    connection: FirefoxWebSocketConnection | null
218+  ): FirefoxBridgeRegisteredClient | null {
219+    if (connection == null) {
220+      return null;
221+    }
222+
223+    const clientId = connection.getClientId();
224+
225+    if (clientId == null) {
226+      return null;
227+    }
228+
229+    return {
230+      clientId,
231+      connectedAt: connection.session.connectedAt,
232+      connectionId: connection.getConnectionId(),
233+      lastMessageAt: connection.session.lastMessageAt,
234+      sendJson: (payload) => connection.sendJson(payload)
235+    };
236+  }
237 }
238 
239 function mapActionToMode(action: FirefoxWsAction): "paused" | "running" | "draining" {
M apps/conductor-daemon/src/index.test.js
+287, -0
  1@@ -600,6 +600,60 @@ async function waitForWebSocketOpen(socket) {
  2   });
  3 }
  4 
  5+async function waitForWebSocketClose(socket) {
  6+  if (socket.readyState === WebSocket.CLOSED) {
  7+    return {
  8+      code: null,
  9+      reason: null
 10+    };
 11+  }
 12+
 13+  return await new Promise((resolve) => {
 14+    socket.addEventListener("close", (event) => {
 15+      resolve({
 16+        code: event.code ?? null,
 17+        reason: event.reason ?? null
 18+      });
 19+    }, {
 20+      once: true
 21+    });
 22+  });
 23+}
 24+
 25+async function connectFirefoxBridgeClient(wsUrl, clientId) {
 26+  const socket = new WebSocket(wsUrl);
 27+  const queue = createWebSocketMessageQueue(socket);
 28+
 29+  await waitForWebSocketOpen(socket);
 30+  socket.send(
 31+    JSON.stringify({
 32+      type: "hello",
 33+      clientId,
 34+      nodeType: "browser",
 35+      nodeCategory: "proxy",
 36+      nodePlatform: "firefox"
 37+    })
 38+  );
 39+
 40+  const helloAck = await queue.next(
 41+    (message) => message.type === "hello_ack" && message.clientId === clientId
 42+  );
 43+  const initialSnapshot = await queue.next(
 44+    (message) => message.type === "state_snapshot" && message.reason === "hello"
 45+  );
 46+  const credentialRequest = await queue.next(
 47+    (message) => message.type === "request_credentials" && message.reason === "hello"
 48+  );
 49+
 50+  return {
 51+    credentialRequest,
 52+    helloAck,
 53+    initialSnapshot,
 54+    queue,
 55+    socket
 56+  };
 57+}
 58+
 59 test("start enters leader state and allows scheduler work only for the lease holder", async () => {
 60   const heartbeatRequests = [];
 61   const leaseRequests = [];
 62@@ -1784,3 +1838,236 @@ test("ConductorRuntime exposes a local Firefox websocket bridge over the local A
 63     recursive: true
 64   });
 65 });
 66+
 67+test("ConductorRuntime exposes Firefox outbound bridge commands and api request responses", async () => {
 68+  const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-firefox-bridge-"));
 69+  const runtime = new ConductorRuntime(
 70+    {
 71+      nodeId: "mini-main",
 72+      host: "mini",
 73+      role: "primary",
 74+      controlApiBase: "https://control.example.test",
 75+      localApiBase: "http://127.0.0.1:0",
 76+      sharedToken: "replace-me",
 77+      paths: {
 78+        runsDir: "/tmp/runs",
 79+        stateDir
 80+      }
 81+    },
 82+    {
 83+      autoStartLoops: false,
 84+      now: () => 100
 85+    }
 86+  );
 87+
 88+  let firstClient = null;
 89+  let secondClient = null;
 90+
 91+  try {
 92+    const snapshot = await runtime.start();
 93+    const bridge = runtime.getFirefoxBridgeService();
 94+
 95+    assert.ok(bridge);
 96+
 97+    firstClient = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-a");
 98+    secondClient = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-b");
 99+
100+    const openTabReceipt = bridge.openTab({
101+      clientId: "firefox-a",
102+      platform: "chatgpt"
103+    });
104+    assert.equal(openTabReceipt.clientId, "firefox-a");
105+
106+    const openTabMessage = await firstClient.queue.next((message) => message.type === "open_tab");
107+    assert.equal(openTabMessage.platform, "chatgpt");
108+
109+    await assert.rejects(
110+      secondClient.queue.next((message) => message.type === "open_tab", 250),
111+      /timed out waiting for websocket message/u
112+    );
113+
114+    const reloadReceipt = bridge.reload({
115+      reason: "integration_test"
116+    });
117+    assert.equal(reloadReceipt.clientId, "firefox-b");
118+
119+    const reloadMessage = await secondClient.queue.next((message) => message.type === "reload");
120+    assert.equal(reloadMessage.reason, "integration_test");
121+
122+    await assert.rejects(
123+      firstClient.queue.next((message) => message.type === "reload", 250),
124+      /timed out waiting for websocket message/u
125+    );
126+
127+    const credentialReceipt = bridge.requestCredentials({
128+      clientId: "firefox-a",
129+      platform: "chatgpt",
130+      reason: "integration_test"
131+    });
132+    assert.equal(credentialReceipt.clientId, "firefox-a");
133+
134+    const credentialMessage = await firstClient.queue.next(
135+      (message) => message.type === "request_credentials" && message.reason === "integration_test"
136+    );
137+    assert.equal(credentialMessage.platform, "chatgpt");
138+
139+    const apiRequestPromise = bridge.apiRequest({
140+      clientId: "firefox-b",
141+      platform: "chatgpt",
142+      method: "POST",
143+      path: "/backend-api/conversation",
144+      body: {
145+        prompt: "hello"
146+      },
147+      headers: {
148+        authorization: "Bearer bridge-token"
149+      }
150+    });
151+    const apiRequestMessage = await secondClient.queue.next((message) => message.type === "api_request");
152+    assert.equal(apiRequestMessage.method, "POST");
153+    assert.equal(apiRequestMessage.path, "/backend-api/conversation");
154+    assert.equal(apiRequestMessage.platform, "chatgpt");
155+    assert.equal(apiRequestMessage.headers.authorization, "Bearer bridge-token");
156+
157+    secondClient.socket.send(
158+      JSON.stringify({
159+        type: "api_response",
160+        id: apiRequestMessage.id,
161+        ok: true,
162+        status: 202,
163+        body: {
164+          accepted: true
165+        }
166+      })
167+    );
168+
169+    const apiResponse = await apiRequestPromise;
170+    assert.equal(apiResponse.clientId, "firefox-b");
171+    assert.equal(apiResponse.connectionId, reloadReceipt.connectionId);
172+    assert.equal(apiResponse.id, apiRequestMessage.id);
173+    assert.equal(apiResponse.ok, true);
174+    assert.equal(apiResponse.status, 202);
175+    assert.deepEqual(apiResponse.body, {
176+      accepted: true
177+    });
178+  } finally {
179+    firstClient?.queue.stop();
180+    secondClient?.queue.stop();
181+    firstClient?.socket.close(1000, "done");
182+    secondClient?.socket.close(1000, "done");
183+    await runtime.stop();
184+    rmSync(stateDir, {
185+      force: true,
186+      recursive: true
187+    });
188+  }
189+});
190+
191+test("Firefox bridge api requests reject on timeout, disconnect, and replacement", async () => {
192+  const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-firefox-bridge-errors-"));
193+  const runtime = new ConductorRuntime(
194+    {
195+      nodeId: "mini-main",
196+      host: "mini",
197+      role: "primary",
198+      controlApiBase: "https://control.example.test",
199+      localApiBase: "http://127.0.0.1:0",
200+      sharedToken: "replace-me",
201+      paths: {
202+        runsDir: "/tmp/runs",
203+        stateDir
204+      }
205+    },
206+    {
207+      autoStartLoops: false,
208+      now: () => 100
209+    }
210+  );
211+
212+  let timeoutClient = null;
213+  let replacementClient = null;
214+  let replacementClientNext = null;
215+
216+  try {
217+    const snapshot = await runtime.start();
218+    const bridge = runtime.getFirefoxBridgeService();
219+
220+    assert.ok(bridge);
221+
222+    timeoutClient = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-timeout");
223+
224+    const timedOutPromise = bridge.apiRequest({
225+      clientId: "firefox-timeout",
226+      platform: "chatgpt",
227+      path: "/backend-api/models",
228+      timeoutMs: 50
229+    });
230+    const timedOutMessage = await timeoutClient.queue.next((message) => message.type === "api_request");
231+    assert.ok(timedOutMessage.id);
232+
233+    await assert.rejects(timedOutPromise, (error) => {
234+      assert.equal(error?.code, "request_timeout");
235+      assert.equal(error?.requestId, timedOutMessage.id);
236+      return true;
237+    });
238+
239+    const disconnectedPromise = bridge.apiRequest({
240+      clientId: "firefox-timeout",
241+      platform: "chatgpt",
242+      path: "/backend-api/models",
243+      timeoutMs: 1_000
244+    });
245+    const disconnectedMessage = await timeoutClient.queue.next((message) => message.type === "api_request");
246+    const disconnectClose = waitForWebSocketClose(timeoutClient.socket);
247+    timeoutClient.socket.close(1000, "disconnect-test");
248+
249+    await assert.rejects(disconnectedPromise, (error) => {
250+      assert.equal(error?.code, "client_disconnected");
251+      assert.equal(error?.requestId, disconnectedMessage.id);
252+      return true;
253+    });
254+    await disconnectClose;
255+    timeoutClient.queue.stop();
256+    timeoutClient = null;
257+
258+    replacementClient = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-replace");
259+
260+    const replacedPromise = bridge.apiRequest({
261+      clientId: "firefox-replace",
262+      platform: "chatgpt",
263+      path: "/backend-api/models",
264+      timeoutMs: 1_000
265+    });
266+    const replacedMessage = await replacementClient.queue.next((message) => message.type === "api_request");
267+    const replacedClose = waitForWebSocketClose(replacementClient.socket);
268+    const replacedRejection = assert.rejects(replacedPromise, (error) => {
269+      assert.equal(error?.code, "client_replaced");
270+      assert.equal(error?.requestId, replacedMessage.id);
271+      return true;
272+    });
273+
274+    replacementClientNext = await connectFirefoxBridgeClient(
275+      snapshot.controlApi.firefoxWsUrl,
276+      "firefox-replace"
277+    );
278+
279+    await replacedRejection;
280+
281+    assert.deepEqual(await replacedClose, {
282+      code: 4001,
283+      reason: "replaced by a newer connection"
284+    });
285+  } finally {
286+    timeoutClient?.queue.stop();
287+    replacementClient?.queue.stop();
288+    replacementClientNext?.queue.stop();
289+    timeoutClient?.socket.close(1000, "done");
290+    replacementClient?.socket.close(1000, "done");
291+    replacementClientNext?.socket.close(1000, "done");
292+    await runtime.stop();
293+    rmSync(stateDir, {
294+      force: true,
295+      recursive: true
296+    });
297+  }
298+});
M apps/conductor-daemon/src/index.ts
+20, -0
 1@@ -15,10 +15,22 @@ import {
 2   ConductorFirefoxWebSocketServer,
 3   buildFirefoxWebSocketUrl
 4 } from "./firefox-ws.js";
 5+import type { FirefoxBridgeService } from "./firefox-bridge.js";
 6 import { handleConductorHttpRequest as handleConductorLocalHttpRequest } from "./local-api.js";
 7 import { ConductorLocalControlPlane } from "./local-control-plane.js";
 8 
 9 export type { ConductorHttpRequest, ConductorHttpResponse } from "./http-types.js";
10+export {
11+  FirefoxBridgeError,
12+  FirefoxBridgeService as ConductorFirefoxBridgeService,
13+  type FirefoxApiRequestCommandInput,
14+  type FirefoxBridgeApiResponse,
15+  type FirefoxBridgeCommandTarget,
16+  type FirefoxBridgeDispatchReceipt,
17+  type FirefoxOpenTabCommandInput,
18+  type FirefoxReloadCommandInput,
19+  type FirefoxRequestCredentialsCommandInput
20+} from "./firefox-bridge.js";
21 export { handleConductorHttpRequest } from "./local-api.js";
22 
23 export type ConductorRole = "primary" | "standby";
24@@ -606,6 +618,10 @@ class ConductorLocalHttpServer {
25     return this.firefoxWebSocketServer.getUrl();
26   }
27 
28+  getFirefoxBridgeService(): FirefoxBridgeService {
29+    return this.firefoxWebSocketServer.getBridgeService();
30+  }
31+
32   async start(): Promise<string> {
33     if (this.server != null) {
34       return this.resolvedBaseUrl;
35@@ -1892,6 +1908,10 @@ export class ConductorRuntime {
36       warnings: buildRuntimeWarnings(this.config)
37     };
38   }
39+
40+  getFirefoxBridgeService(): FirefoxBridgeService | null {
41+    return this.localApiServer?.getFirefoxBridgeService() ?? null;
42+  }
43 }
44 
45 async function waitForShutdownSignal(processLike: ConductorProcessLike | undefined): Promise<string | null> {
M docs/api/firefox-local-ws.md
+91, -2
  1@@ -41,6 +41,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
  2 | `action_request` | 请求执行 `pause` / `resume` / `drain` |
  3 | `credentials` | 上送浏览器凭证快照;server 只保留平台、header 数量、时间戳等最小元数据 |
  4 | `api_endpoints` | 上送当前 request hook 观察到的 endpoint 列表 |
  5+| `api_response` | 对服务端下发的 `api_request` 回包,按 `id` 做 request-response 关联 |
  6 | `client_log` | 可选日志消息;当前 server 只接收,不做业务处理 |
  7 
  8 ### server -> client
  9@@ -50,7 +51,10 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 10 | `hello_ack` | 握手确认,回传 protocol/version、WS URL、支持的消息类型 |
 11 | `state_snapshot` | 当前 server/system/browser 摘要 |
 12 | `action_result` | `action_request` 的执行结果;成功时直接回传最新 `system` |
 13+| `open_tab` | 指示浏览器打开或激活目标平台标签页 |
 14+| `api_request` | 由 server 发起、浏览器代发的 API 请求;浏览器完成后回 `api_response` |
 15 | `request_credentials` | 提示浏览器重新发送 `credentials` |
 16+| `reload` | 指示插件管理页重载当前 controller 页面 |
 17 | `error` | 非法 JSON、未知消息类型或未实现消息 |
 18 
 19 ## 关键 payload
 20@@ -78,8 +82,8 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 21   "wsUrl": "ws://100.71.210.78:4317/ws/firefox",
 22   "localApiBase": "http://100.71.210.78:4317",
 23   "supports": {
 24-    "inbound": ["hello", "state_request", "action_request", "credentials", "api_endpoints", "client_log"],
 25-    "outbound": ["hello_ack", "state_snapshot", "action_result", "request_credentials", "error"]
 26+    "inbound": ["hello", "state_request", "action_request", "credentials", "api_endpoints", "client_log", "api_response"],
 27+    "outbound": ["hello_ack", "state_snapshot", "action_result", "open_tab", "api_request", "request_credentials", "reload", "error"]
 28   }
 29 }
 30 ```
 31@@ -185,6 +189,91 @@ server 行为:
 32 - 不在 `state_snapshot` 中回显 header 原文
 33 - 只把 `platform`、`header_count`、`captured_at` 汇总到 browser snapshot
 34 
 35+### `open_tab`
 36+
 37+```json
 38+{
 39+  "type": "open_tab",
 40+  "platform": "chatgpt"
 41+}
 42+```
 43+
 44+说明:
 45+
 46+- 可显式指定 `clientId` 目标;未指定时 server 会选最近活跃的 Firefox client
 47+- 当前插件按 `platform` 激活或拉起对应页面
 48+
 49+### `request_credentials`
 50+
 51+```json
 52+{
 53+  "type": "request_credentials",
 54+  "platform": "chatgpt",
 55+  "reason": "hello"
 56+}
 57+```
 58+
 59+说明:
 60+
 61+- 插件收到后会重新上送对应平台的 `credentials`
 62+- `platform` 可省略,表示尽量刷新全部已知平台的凭证快照
 63+
 64+### `reload`
 65+
 66+```json
 67+{
 68+  "type": "reload",
 69+  "reason": "operator_requested_reload"
 70+}
 71+```
 72+
 73+### `api_request` / `api_response`
 74+
 75+服务端下发:
 76+
 77+```json
 78+{
 79+  "type": "api_request",
 80+  "id": "req-browser-1",
 81+  "platform": "chatgpt",
 82+  "method": "POST",
 83+  "path": "/backend-api/conversation",
 84+  "headers": {
 85+    "authorization": "Bearer ..."
 86+  },
 87+  "body": {
 88+    "prompt": "hello"
 89+  }
 90+}
 91+```
 92+
 93+浏览器回包:
 94+
 95+```json
 96+{
 97+  "type": "api_response",
 98+  "id": "req-browser-1",
 99+  "ok": true,
100+  "status": 200,
101+  "body": {
102+    "conversation_id": "abc"
103+  },
104+  "error": null
105+}
106+```
107+
108+请求生命周期:
109+
110+- `conductor-daemon` 为每个 `api_request` 绑定唯一 `id`
111+- 可按显式 `clientId` 投递,也可默认投递到最近活跃 client
112+- 回包必须带同一个 `id`,由 bridge broker 完成 request-response 关联
113+- 若 client 超时未回、连接断开,或同 `clientId` 被新连接替换,等待中的请求会立即失败
114+
115+当前非目标:
116+
117+- 这里还没有最终 `/v1/browser/*` HTTP 产品接口
118+- 本阶段只提供 WS transport、client registry 和 request-response 基础能力
119+
120 ## 最小 smoke
121 
122 ```bash
M docs/firefox/README.md
+11, -0
 1@@ -82,12 +82,23 @@
 2 
 3 - `hello_ack`
 4 - `state_snapshot`
 5+- `open_tab`
 6+- `api_request`
 7 - `request_credentials`
 8+- `reload`
 9 - `action_result`
10 - `error`
11 
12 其中 `state_snapshot` 用来驱动管理页里的 WS 状态展示。
13 
14+`api_request` / `api_response` 现在已经被正式纳入本地 WS bridge:
15+
16+- server 可以按 `clientId` 或默认最近活跃 client 下发 `api_request`
17+- 插件完成代发后会回 `api_response`
18+- daemon 会跟踪请求生命周期,并处理超时、client disconnect、同 `clientId` replacement
19+
20+当前仍然没有对外产品化的 `/v1/browser/*` HTTP 接口,这一层只补 transport / registry / request-response。
21+
22 ## HTTP 协议
23 
24 读取: