baa-conductor

git clone 

commit
1786c1e
parent
fdcc3fa
author
im_wower
date
2026-03-22 18:04:36 +0800 CST
feat(conductor-daemon): add local firefox ws bridge
10 files changed,  +1876, -43
A apps/conductor-daemon/src/firefox-ws.ts
+881, -0
  1@@ -0,0 +1,881 @@
  2+import { createHash, randomUUID } from "node:crypto";
  3+import type { IncomingMessage } from "node:http";
  4+import type { Socket } from "node:net";
  5+import type { ControlPlaneRepository } from "../../../packages/db/dist/index.js";
  6+
  7+import { buildSystemStateData, setAutomationMode } from "./local-api.js";
  8+import type { ConductorRuntimeSnapshot } from "./index.js";
  9+
 10+const FIREFOX_WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
 11+const FIREFOX_WS_PROTOCOL = "baa.firefox.local";
 12+const FIREFOX_WS_PROTOCOL_VERSION = 1;
 13+const FIREFOX_WS_STATE_POLL_INTERVAL_MS = 2_000;
 14+const MAX_FRAME_PAYLOAD_BYTES = 1024 * 1024;
 15+const CLIENT_REPLACED_CLOSE_CODE = 4001;
 16+const INVALID_MESSAGE_CLOSE_CODE = 4002;
 17+const NORMAL_CLOSE_CODE = 1000;
 18+const UNSUPPORTED_DATA_CLOSE_CODE = 1003;
 19+const MESSAGE_TOO_LARGE_CLOSE_CODE = 1009;
 20+
 21+export const FIREFOX_WS_PATH = "/ws/firefox";
 22+
 23+type IntervalHandle = ReturnType<typeof globalThis.setInterval>;
 24+type FirefoxWsAction = "pause" | "resume" | "drain";
 25+
 26+interface FirefoxWebSocketServerOptions {
 27+  baseUrlLoader: () => string;
 28+  now?: () => number;
 29+  repository: ControlPlaneRepository;
 30+  snapshotLoader: () => ConductorRuntimeSnapshot;
 31+}
 32+
 33+interface FirefoxBrowserCredentialSummary {
 34+  capturedAt: number;
 35+  headerCount: number;
 36+}
 37+
 38+interface FirefoxBrowserHookSummary {
 39+  endpoints: string[];
 40+  updatedAt: number;
 41+}
 42+
 43+interface FirefoxBrowserSession {
 44+  clientId: string | null;
 45+  connectedAt: number;
 46+  credentials: Map<string, FirefoxBrowserCredentialSummary>;
 47+  id: string;
 48+  lastMessageAt: number;
 49+  nodeCategory: string | null;
 50+  nodePlatform: string | null;
 51+  nodeType: string | null;
 52+  requestHooks: Map<string, FirefoxBrowserHookSummary>;
 53+}
 54+
 55+function asRecord(value: unknown): Record<string, unknown> | null {
 56+  if (value === null || typeof value !== "object" || Array.isArray(value)) {
 57+    return null;
 58+  }
 59+
 60+  return value as Record<string, unknown>;
 61+}
 62+
 63+function normalizeNonEmptyString(value: unknown): string | null {
 64+  if (typeof value !== "string") {
 65+    return null;
 66+  }
 67+
 68+  const normalized = value.trim();
 69+  return normalized === "" ? null : normalized;
 70+}
 71+
 72+function readFirstString(
 73+  input: Record<string, unknown>,
 74+  keys: readonly string[]
 75+): string | null {
 76+  for (const key of keys) {
 77+    const value = normalizeNonEmptyString(input[key]);
 78+
 79+    if (value != null) {
 80+      return value;
 81+    }
 82+  }
 83+
 84+  return null;
 85+}
 86+
 87+function readStringArray(
 88+  input: Record<string, unknown>,
 89+  key: string
 90+): string[] {
 91+  const rawValue = input[key];
 92+
 93+  if (!Array.isArray(rawValue)) {
 94+    return [];
 95+  }
 96+
 97+  const values = new Set<string>();
 98+
 99+  for (const entry of rawValue) {
100+    const value = normalizeNonEmptyString(entry);
101+
102+    if (value != null) {
103+      values.add(value);
104+    }
105+  }
106+
107+  return [...values].sort((left, right) => left.localeCompare(right));
108+}
109+
110+function countObjectKeys(value: unknown): number {
111+  const record = asRecord(value);
112+
113+  if (record == null) {
114+    return 0;
115+  }
116+
117+  return Object.keys(record).length;
118+}
119+
120+function readTimestampMilliseconds(input: Record<string, unknown>, key: string, fallback: number): number {
121+  const value = input[key];
122+
123+  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
124+    return fallback;
125+  }
126+
127+  return Math.round(value);
128+}
129+
130+function toUnixMilliseconds(value: number | null | undefined): number | null {
131+  if (value == null) {
132+    return null;
133+  }
134+
135+  return value * 1000;
136+}
137+
138+function normalizePathname(value: string): string {
139+  const normalized = value.replace(/\/+$/u, "");
140+  return normalized === "" ? "/" : normalized;
141+}
142+
143+function buildWebSocketAcceptValue(key: string): string {
144+  return createHash("sha1").update(`${key}${FIREFOX_WS_GUID}`).digest("base64");
145+}
146+
147+function buildClosePayload(code: number, reason: string): Buffer {
148+  const reasonBuffer = Buffer.from(reason, "utf8");
149+  const payload = Buffer.allocUnsafe(2 + reasonBuffer.length);
150+  payload.writeUInt16BE(code, 0);
151+  reasonBuffer.copy(payload, 2);
152+  return payload;
153+}
154+
155+function buildFrame(opcode: number, payload: Buffer = Buffer.alloc(0)): Buffer {
156+  let header: Buffer;
157+
158+  if (payload.length < 126) {
159+    header = Buffer.allocUnsafe(2);
160+    header[0] = 0x80 | opcode;
161+    header[1] = payload.length;
162+    return Buffer.concat([header, payload]);
163+  }
164+
165+  if (payload.length <= 0xffff) {
166+    header = Buffer.allocUnsafe(4);
167+    header[0] = 0x80 | opcode;
168+    header[1] = 126;
169+    header.writeUInt16BE(payload.length, 2);
170+    return Buffer.concat([header, payload]);
171+  }
172+
173+  header = Buffer.allocUnsafe(10);
174+  header[0] = 0x80 | opcode;
175+  header[1] = 127;
176+  header.writeBigUInt64BE(BigInt(payload.length), 2);
177+  return Buffer.concat([header, payload]);
178+}
179+
180+export function buildFirefoxWebSocketUrl(localApiBase: string | null | undefined): string | null {
181+  const normalized = normalizeNonEmptyString(localApiBase);
182+
183+  if (normalized == null) {
184+    return null;
185+  }
186+
187+  const url = new URL(normalized);
188+  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
189+  url.pathname = FIREFOX_WS_PATH;
190+  url.search = "";
191+  url.hash = "";
192+  return url.toString();
193+}
194+
195+class FirefoxWebSocketConnection {
196+  readonly session: FirefoxBrowserSession;
197+  private closed = false;
198+  private buffer = Buffer.alloc(0);
199+
200+  constructor(
201+    private readonly socket: Socket,
202+    private readonly server: ConductorFirefoxWebSocketServer
203+  ) {
204+    const now = Date.now();
205+
206+    this.session = {
207+      clientId: null,
208+      connectedAt: now,
209+      credentials: new Map(),
210+      id: randomUUID(),
211+      lastMessageAt: now,
212+      nodeCategory: null,
213+      nodePlatform: null,
214+      nodeType: null,
215+      requestHooks: new Map()
216+    };
217+
218+    this.socket.setNoDelay(true);
219+    this.socket.on("data", (chunk) => {
220+      this.handleData(chunk);
221+    });
222+    this.socket.on("close", () => {
223+      this.handleClosed();
224+    });
225+    this.socket.on("end", () => {
226+      this.handleClosed();
227+    });
228+    this.socket.on("error", () => {
229+      this.handleClosed();
230+    });
231+  }
232+
233+  attachHead(head: Buffer): void {
234+    if (head.length > 0) {
235+      this.handleData(head);
236+    }
237+  }
238+
239+  getClientId(): string | null {
240+    return this.session.clientId;
241+  }
242+
243+  setClientMetadata(metadata: {
244+    clientId: string;
245+    nodeCategory: string | null;
246+    nodePlatform: string | null;
247+    nodeType: string | null;
248+  }): void {
249+    this.session.clientId = metadata.clientId;
250+    this.session.nodeCategory = metadata.nodeCategory;
251+    this.session.nodePlatform = metadata.nodePlatform;
252+    this.session.nodeType = metadata.nodeType;
253+  }
254+
255+  updateCredential(platform: string, summary: FirefoxBrowserCredentialSummary): void {
256+    this.session.credentials.set(platform, summary);
257+  }
258+
259+  updateRequestHook(platform: string, summary: FirefoxBrowserHookSummary): void {
260+    this.session.requestHooks.set(platform, summary);
261+  }
262+
263+  touch(): void {
264+    this.session.lastMessageAt = Date.now();
265+  }
266+
267+  describe(): Record<string, unknown> {
268+    const credentials = [...this.session.credentials.entries()]
269+      .sort(([left], [right]) => left.localeCompare(right))
270+      .map(([platform, summary]) => ({
271+        platform,
272+        captured_at: summary.capturedAt,
273+        header_count: summary.headerCount
274+      }));
275+    const requestHooks = [...this.session.requestHooks.entries()]
276+      .sort(([left], [right]) => left.localeCompare(right))
277+      .map(([platform, summary]) => ({
278+        platform,
279+        endpoint_count: summary.endpoints.length,
280+        endpoints: [...summary.endpoints],
281+        updated_at: summary.updatedAt
282+      }));
283+
284+    return {
285+      client_id: this.session.clientId ?? `anonymous-${this.session.id.slice(0, 8)}`,
286+      connected_at: this.session.connectedAt,
287+      connection_id: this.session.id,
288+      credentials,
289+      last_message_at: this.session.lastMessageAt,
290+      node_category: this.session.nodeCategory,
291+      node_platform: this.session.nodePlatform,
292+      node_type: this.session.nodeType,
293+      request_hooks: requestHooks
294+    };
295+  }
296+
297+  sendJson(payload: Record<string, unknown>): boolean {
298+    if (this.closed) {
299+      return false;
300+    }
301+
302+    try {
303+      const body = Buffer.from(`${JSON.stringify(payload)}\n`, "utf8");
304+      this.socket.write(buildFrame(0x1, body));
305+      return true;
306+    } catch {
307+      this.handleClosed();
308+      return false;
309+    }
310+  }
311+
312+  close(code: number = NORMAL_CLOSE_CODE, reason: string = ""): void {
313+    if (this.closed) {
314+      return;
315+    }
316+
317+    this.closed = true;
318+
319+    try {
320+      this.socket.write(buildFrame(0x8, buildClosePayload(code, reason)));
321+    } catch {
322+      // Best-effort close frame.
323+    }
324+
325+    this.socket.end();
326+    this.socket.destroySoon?.();
327+    this.server.unregister(this);
328+  }
329+
330+  private handleClosed(): void {
331+    if (this.closed) {
332+      return;
333+    }
334+
335+    this.closed = true;
336+    this.socket.destroy();
337+    this.server.unregister(this);
338+  }
339+
340+  private handleData(chunk: Buffer): void {
341+    if (this.closed) {
342+      return;
343+    }
344+
345+    this.buffer = Buffer.concat([this.buffer, chunk]);
346+
347+    while (true) {
348+      const frame = this.readFrame();
349+
350+      if (frame == null) {
351+        return;
352+      }
353+
354+      if (frame.opcode === 0x8) {
355+        this.close();
356+        return;
357+      }
358+
359+      if (frame.opcode === 0x9) {
360+        this.socket.write(buildFrame(0xA, frame.payload));
361+        continue;
362+      }
363+
364+      if (frame.opcode === 0xA) {
365+        continue;
366+      }
367+
368+      if (frame.opcode !== 0x1) {
369+        this.close(UNSUPPORTED_DATA_CLOSE_CODE, "Only text frames are supported.");
370+        return;
371+      }
372+
373+      this.touch();
374+      this.server.handleClientMessage(this, frame.payload.toString("utf8"));
375+    }
376+  }
377+
378+  private readFrame(): { opcode: number; payload: Buffer } | null {
379+    if (this.buffer.length < 2) {
380+      return null;
381+    }
382+
383+    const firstByte = this.buffer[0];
384+    const secondByte = this.buffer[1];
385+
386+    if (firstByte == null || secondByte == null) {
387+      return null;
388+    }
389+
390+    const fin = (firstByte & 0x80) !== 0;
391+    const opcode = firstByte & 0x0f;
392+    const masked = (secondByte & 0x80) !== 0;
393+    let payloadLength = secondByte & 0x7f;
394+    let offset = 2;
395+
396+    if (!fin) {
397+      this.close(UNSUPPORTED_DATA_CLOSE_CODE, "Fragmented frames are not supported.");
398+      return null;
399+    }
400+
401+    if (!masked) {
402+      this.close(INVALID_MESSAGE_CLOSE_CODE, "Client frames must be masked.");
403+      return null;
404+    }
405+
406+    if (payloadLength === 126) {
407+      if (this.buffer.length < offset + 2) {
408+        return null;
409+      }
410+
411+      payloadLength = this.buffer.readUInt16BE(offset);
412+      offset += 2;
413+    } else if (payloadLength === 127) {
414+      if (this.buffer.length < offset + 8) {
415+        return null;
416+      }
417+
418+      const extendedLength = this.buffer.readBigUInt64BE(offset);
419+
420+      if (extendedLength > BigInt(MAX_FRAME_PAYLOAD_BYTES)) {
421+        this.close(MESSAGE_TOO_LARGE_CLOSE_CODE, "Frame payload is too large.");
422+        return null;
423+      }
424+
425+      payloadLength = Number(extendedLength);
426+      offset += 8;
427+    }
428+
429+    if (payloadLength > MAX_FRAME_PAYLOAD_BYTES) {
430+      this.close(MESSAGE_TOO_LARGE_CLOSE_CODE, "Frame payload is too large.");
431+      return null;
432+    }
433+
434+    if (this.buffer.length < offset + 4 + payloadLength) {
435+      return null;
436+    }
437+
438+    const mask = this.buffer.subarray(offset, offset + 4);
439+    offset += 4;
440+    const payload = this.buffer.subarray(offset, offset + payloadLength);
441+    const unmasked = Buffer.allocUnsafe(payloadLength);
442+
443+    for (let index = 0; index < payloadLength; index += 1) {
444+      const maskByte = mask[index % 4];
445+      const payloadByte = payload[index];
446+
447+      if (maskByte == null || payloadByte == null) {
448+        this.close(INVALID_MESSAGE_CLOSE_CODE, "Malformed masked payload.");
449+        return null;
450+      }
451+
452+      unmasked[index] = payloadByte ^ maskByte;
453+    }
454+
455+    this.buffer = this.buffer.subarray(offset + payloadLength);
456+    return {
457+      opcode,
458+      payload: unmasked
459+    };
460+  }
461+}
462+
463+export class ConductorFirefoxWebSocketServer {
464+  private readonly baseUrlLoader: () => string;
465+  private readonly now: () => number;
466+  private readonly repository: ControlPlaneRepository;
467+  private readonly snapshotLoader: () => ConductorRuntimeSnapshot;
468+  private readonly connections = new Set<FirefoxWebSocketConnection>();
469+  private readonly connectionsByClientId = new Map<string, FirefoxWebSocketConnection>();
470+  private broadcastQueue: Promise<void> = Promise.resolve();
471+  private lastSnapshotSignature: string | null = null;
472+  private pollTimer: IntervalHandle | null = null;
473+
474+  constructor(options: FirefoxWebSocketServerOptions) {
475+    this.baseUrlLoader = options.baseUrlLoader;
476+    this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
477+    this.repository = options.repository;
478+    this.snapshotLoader = options.snapshotLoader;
479+  }
480+
481+  getUrl(): string | null {
482+    return buildFirefoxWebSocketUrl(this.baseUrlLoader());
483+  }
484+
485+  start(): void {
486+    if (this.pollTimer != null) {
487+      return;
488+    }
489+
490+    this.pollTimer = globalThis.setInterval(() => {
491+      void this.broadcastStateSnapshot("poll");
492+    }, FIREFOX_WS_STATE_POLL_INTERVAL_MS);
493+  }
494+
495+  async stop(): Promise<void> {
496+    if (this.pollTimer != null) {
497+      globalThis.clearInterval(this.pollTimer);
498+      this.pollTimer = null;
499+    }
500+
501+    for (const connection of [...this.connections]) {
502+      connection.close(1001, "server shutdown");
503+    }
504+
505+    this.connections.clear();
506+    this.connectionsByClientId.clear();
507+    this.lastSnapshotSignature = null;
508+    await this.broadcastQueue.catch(() => {});
509+  }
510+
511+  handleUpgrade(request: IncomingMessage, socket: Socket, head: Buffer): boolean {
512+    const pathname = normalizePathname(new URL(request.url ?? "/", "http://127.0.0.1").pathname);
513+
514+    if (pathname !== FIREFOX_WS_PATH) {
515+      socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
516+      socket.destroy();
517+      return false;
518+    }
519+
520+    const upgrade = normalizeNonEmptyString(request.headers.upgrade);
521+    const key = normalizeNonEmptyString(request.headers["sec-websocket-key"]);
522+    const version = normalizeNonEmptyString(request.headers["sec-websocket-version"]);
523+
524+    if (request.method !== "GET" || upgrade?.toLowerCase() !== "websocket" || key == null) {
525+      socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
526+      socket.destroy();
527+      return false;
528+    }
529+
530+    if (version !== "13") {
531+      socket.write(
532+        "HTTP/1.1 426 Upgrade Required\r\nSec-WebSocket-Version: 13\r\nConnection: close\r\n\r\n"
533+      );
534+      socket.destroy();
535+      return false;
536+    }
537+
538+    socket.write(
539+      [
540+        "HTTP/1.1 101 Switching Protocols",
541+        "Upgrade: websocket",
542+        "Connection: Upgrade",
543+        `Sec-WebSocket-Accept: ${buildWebSocketAcceptValue(key)}`,
544+        "",
545+        ""
546+      ].join("\r\n")
547+    );
548+
549+    const connection = new FirefoxWebSocketConnection(socket, this);
550+    this.connections.add(connection);
551+    connection.attachHead(head);
552+    return true;
553+  }
554+
555+  unregister(connection: FirefoxWebSocketConnection): void {
556+    this.connections.delete(connection);
557+
558+    const clientId = connection.getClientId();
559+
560+    if (clientId != null && this.connectionsByClientId.get(clientId) === connection) {
561+      this.connectionsByClientId.delete(clientId);
562+    }
563+
564+    void this.broadcastStateSnapshot("disconnect", {
565+      force: true
566+    });
567+  }
568+
569+  handleClientMessage(connection: FirefoxWebSocketConnection, rawMessage: string): void {
570+    void this.dispatchClientMessage(connection, rawMessage);
571+  }
572+
573+  private async dispatchClientMessage(
574+    connection: FirefoxWebSocketConnection,
575+    rawMessage: string
576+  ): Promise<void> {
577+    let message: Record<string, unknown> | null = null;
578+
579+    try {
580+      message = asRecord(JSON.parse(rawMessage));
581+    } catch {
582+      this.sendError(connection, "invalid_json", "WS message body must be valid JSON.");
583+      return;
584+    }
585+
586+    if (message == null) {
587+      this.sendError(connection, "invalid_message", "WS message must be a JSON object.");
588+      return;
589+    }
590+
591+    const type = normalizeNonEmptyString(message.type);
592+
593+    if (type == null) {
594+      this.sendError(connection, "invalid_message", "WS message requires a non-empty type.");
595+      return;
596+    }
597+
598+    connection.touch();
599+
600+    switch (type) {
601+      case "hello":
602+        await this.handleHello(connection, message);
603+        return;
604+      case "state_request":
605+        await this.sendStateSnapshotTo(connection, "state_request");
606+        return;
607+      case "action_request":
608+        await this.handleActionRequest(connection, message);
609+        return;
610+      case "credentials":
611+        await this.handleCredentials(connection, message);
612+        return;
613+      case "api_endpoints":
614+        await this.handleApiEndpoints(connection, message);
615+        return;
616+      case "client_log":
617+        return;
618+      case "api_request":
619+      case "api_response":
620+        this.sendError(
621+          connection,
622+          "not_implemented",
623+          "API proxy over WS is not implemented on conductor-daemon; only control snapshots and action requests are supported."
624+        );
625+        return;
626+      default:
627+        this.sendError(connection, "unsupported_message_type", `Unsupported WS message type: ${type}.`);
628+    }
629+  }
630+
631+  private async handleHello(
632+    connection: FirefoxWebSocketConnection,
633+    message: Record<string, unknown>
634+  ): Promise<void> {
635+    const clientId =
636+      readFirstString(message, ["clientId", "client_id"]) ?? `anonymous-${connection.session.id.slice(0, 8)}`;
637+    const previousConnection = this.connectionsByClientId.get(clientId);
638+
639+    if (previousConnection != null && previousConnection !== connection) {
640+      previousConnection.close(CLIENT_REPLACED_CLOSE_CODE, "replaced by a newer connection");
641+    }
642+
643+    connection.setClientMetadata({
644+      clientId,
645+      nodeCategory: readFirstString(message, ["nodeCategory", "node_category"]),
646+      nodePlatform: readFirstString(message, ["nodePlatform", "node_platform"]),
647+      nodeType: readFirstString(message, ["nodeType", "node_type"])
648+    });
649+    this.connectionsByClientId.set(clientId, connection);
650+
651+    connection.sendJson({
652+      type: "hello_ack",
653+      clientId,
654+      localApiBase: this.snapshotLoader().controlApi.localApiBase ?? null,
655+      protocol: FIREFOX_WS_PROTOCOL,
656+      version: FIREFOX_WS_PROTOCOL_VERSION,
657+      wsUrl: this.getUrl(),
658+      supports: {
659+        inbound: [
660+          "hello",
661+          "state_request",
662+          "action_request",
663+          "credentials",
664+          "api_endpoints",
665+          "client_log"
666+        ],
667+        outbound: [
668+          "hello_ack",
669+          "state_snapshot",
670+          "action_result",
671+          "request_credentials",
672+          "error"
673+        ]
674+      }
675+    });
676+    await this.sendStateSnapshotTo(connection, "hello");
677+    connection.sendJson({
678+      type: "request_credentials",
679+      reason: "hello"
680+    });
681+    await this.broadcastStateSnapshot("client_hello", {
682+      force: true
683+    });
684+  }
685+
686+  private async handleActionRequest(
687+    connection: FirefoxWebSocketConnection,
688+    message: Record<string, unknown>
689+  ): Promise<void> {
690+    const action = normalizeNonEmptyString(message.action);
691+    const requestId = readFirstString(message, ["requestId", "request_id", "id"]) ?? randomUUID();
692+
693+    if (action !== "pause" && action !== "resume" && action !== "drain") {
694+      connection.sendJson({
695+        type: "action_result",
696+        action,
697+        ok: false,
698+        requestId,
699+        error: "invalid_action",
700+        message: "action must be one of pause, resume, or drain."
701+      });
702+      return;
703+    }
704+
705+    const requestedBy =
706+      readFirstString(message, ["requestedBy", "requested_by"])
707+      ?? connection.getClientId()
708+      ?? "browser_admin";
709+    const reason =
710+      readFirstString(message, ["reason"])
711+      ?? `ws_${action}_requested`;
712+    const source =
713+      readFirstString(message, ["source"])
714+      ?? "firefox_extension_ws";
715+
716+    try {
717+      const system = await setAutomationMode(this.repository, {
718+        mode: mapActionToMode(action),
719+        reason,
720+        requestedBy,
721+        source,
722+        updatedAt: this.now()
723+      });
724+
725+      connection.sendJson({
726+        type: "action_result",
727+        action,
728+        ok: true,
729+        requestId,
730+        system
731+      });
732+      await this.broadcastStateSnapshot("action_request", {
733+        force: true
734+      });
735+    } catch (error) {
736+      connection.sendJson({
737+        type: "action_result",
738+        action,
739+        ok: false,
740+        requestId,
741+        error: "action_failed",
742+        message: error instanceof Error ? error.message : String(error)
743+      });
744+    }
745+  }
746+
747+  private async handleCredentials(
748+    connection: FirefoxWebSocketConnection,
749+    message: Record<string, unknown>
750+  ): Promise<void> {
751+    const platform = readFirstString(message, ["platform"]);
752+
753+    if (platform == null) {
754+      this.sendError(connection, "invalid_message", "credentials requires a platform field.");
755+      return;
756+    }
757+
758+    connection.updateCredential(platform, {
759+      capturedAt: readTimestampMilliseconds(message, "timestamp", Date.now()),
760+      headerCount: countObjectKeys(message.headers)
761+    });
762+    await this.broadcastStateSnapshot("credentials");
763+  }
764+
765+  private async handleApiEndpoints(
766+    connection: FirefoxWebSocketConnection,
767+    message: Record<string, unknown>
768+  ): Promise<void> {
769+    const platform = readFirstString(message, ["platform"]);
770+
771+    if (platform == null) {
772+      this.sendError(connection, "invalid_message", "api_endpoints requires a platform field.");
773+      return;
774+    }
775+
776+    connection.updateRequestHook(platform, {
777+      endpoints: readStringArray(message, "endpoints"),
778+      updatedAt: Date.now()
779+    });
780+    await this.broadcastStateSnapshot("api_endpoints");
781+  }
782+
783+  private async buildStateSnapshot(): Promise<Record<string, unknown>> {
784+    const runtime = this.snapshotLoader();
785+    const clients = [...this.connections]
786+      .map((connection) => connection.describe())
787+      .sort((left, right) =>
788+        String(left.client_id ?? "").localeCompare(String(right.client_id ?? ""))
789+      );
790+
791+    return {
792+      browser: {
793+        client_count: clients.length,
794+        clients
795+      },
796+      server: {
797+        host: runtime.daemon.host,
798+        identity: runtime.identity,
799+        lease_state: runtime.daemon.leaseState,
800+        local_api_base: runtime.controlApi.localApiBase ?? null,
801+        node_id: runtime.daemon.nodeId,
802+        role: runtime.daemon.role,
803+        scheduler_enabled: runtime.daemon.schedulerEnabled,
804+        started: runtime.runtime.started,
805+        started_at: toUnixMilliseconds(runtime.runtime.startedAt),
806+        ws_path: FIREFOX_WS_PATH,
807+        ws_url: this.getUrl()
808+      },
809+      system: await buildSystemStateData(this.repository),
810+      version: FIREFOX_WS_PROTOCOL_VERSION
811+    };
812+  }
813+
814+  private async sendStateSnapshotTo(
815+    connection: FirefoxWebSocketConnection,
816+    reason: string
817+  ): Promise<void> {
818+    const snapshot = await this.buildStateSnapshot();
819+    connection.sendJson({
820+      type: "state_snapshot",
821+      reason,
822+      snapshot
823+    });
824+  }
825+
826+  private async broadcastStateSnapshot(
827+    reason: string,
828+    options: {
829+      force?: boolean;
830+    } = {}
831+  ): Promise<void> {
832+    if (this.connections.size === 0) {
833+      return;
834+    }
835+
836+    const task = this.broadcastQueue.then(async () => {
837+      const snapshot = await this.buildStateSnapshot();
838+      const signature = JSON.stringify(snapshot);
839+
840+      if (!options.force && this.lastSnapshotSignature === signature) {
841+        return;
842+      }
843+
844+      this.lastSnapshotSignature = signature;
845+      const payload = {
846+        type: "state_snapshot",
847+        reason,
848+        snapshot
849+      };
850+
851+      for (const connection of this.connections) {
852+        connection.sendJson(payload);
853+      }
854+    });
855+
856+    this.broadcastQueue = task.catch(() => {});
857+    await task;
858+  }
859+
860+  private sendError(
861+    connection: FirefoxWebSocketConnection,
862+    code: string,
863+    message: string
864+  ): void {
865+    connection.sendJson({
866+      type: "error",
867+      code,
868+      message
869+    });
870+  }
871+}
872+
873+function mapActionToMode(action: FirefoxWsAction): "paused" | "running" | "draining" {
874+  switch (action) {
875+    case "pause":
876+      return "paused";
877+    case "resume":
878+      return "running";
879+    case "drain":
880+      return "draining";
881+  }
882+}
M apps/conductor-daemon/src/index.test.js
+272, -0
  1@@ -180,6 +180,7 @@ async function createLocalApiFixture() {
  2   const snapshot = {
  3     controlApi: {
  4       baseUrl: "https://control.example.test",
  5+      firefoxWsUrl: "ws://127.0.0.1:4317/ws/firefox",
  6       hasSharedToken: false,
  7       localApiBase: "http://127.0.0.1:4317",
  8       usesPlaceholderToken: false
  9@@ -215,6 +216,109 @@ function parseJsonBody(response) {
 10   return JSON.parse(response.body);
 11 }
 12 
 13+function createWebSocketMessageQueue(socket) {
 14+  const messages = [];
 15+  const waiters = [];
 16+
 17+  const onMessage = (event) => {
 18+    let payload = null;
 19+
 20+    try {
 21+      payload = JSON.parse(event.data);
 22+    } catch {
 23+      return;
 24+    }
 25+
 26+    const waiterIndex = waiters.findIndex((waiter) => waiter.predicate(payload));
 27+
 28+    if (waiterIndex >= 0) {
 29+      const [waiter] = waiters.splice(waiterIndex, 1);
 30+
 31+      if (waiter) {
 32+        clearTimeout(waiter.timer);
 33+        waiter.resolve(payload);
 34+      }
 35+
 36+      return;
 37+    }
 38+
 39+    messages.push(payload);
 40+  };
 41+
 42+  const onClose = () => {
 43+    while (waiters.length > 0) {
 44+      const waiter = waiters.shift();
 45+
 46+      if (waiter) {
 47+        clearTimeout(waiter.timer);
 48+        waiter.reject(new Error("websocket closed before the expected message arrived"));
 49+      }
 50+    }
 51+  };
 52+
 53+  socket.addEventListener("message", onMessage);
 54+  socket.addEventListener("close", onClose);
 55+
 56+  return {
 57+    async next(predicate, timeoutMs = 5_000) {
 58+      const existingIndex = messages.findIndex((message) => predicate(message));
 59+
 60+      if (existingIndex >= 0) {
 61+        const [message] = messages.splice(existingIndex, 1);
 62+        return message;
 63+      }
 64+
 65+      return await new Promise((resolve, reject) => {
 66+        const timer = setTimeout(() => {
 67+          const waiterIndex = waiters.findIndex((waiter) => waiter.timer === timer);
 68+
 69+          if (waiterIndex >= 0) {
 70+            waiters.splice(waiterIndex, 1);
 71+          }
 72+
 73+          reject(new Error("timed out waiting for websocket message"));
 74+        }, timeoutMs);
 75+
 76+        waiters.push({
 77+          predicate,
 78+          reject,
 79+          resolve,
 80+          timer
 81+        });
 82+      });
 83+    },
 84+    stop() {
 85+      socket.removeEventListener("message", onMessage);
 86+      socket.removeEventListener("close", onClose);
 87+      onClose();
 88+    }
 89+  };
 90+}
 91+
 92+async function waitForWebSocketOpen(socket) {
 93+  if (socket.readyState === WebSocket.OPEN) {
 94+    return;
 95+  }
 96+
 97+  await new Promise((resolve, reject) => {
 98+    const onOpen = () => {
 99+      socket.removeEventListener("error", onError);
100+      resolve();
101+    };
102+    const onError = () => {
103+      socket.removeEventListener("open", onOpen);
104+      reject(new Error("websocket failed to open"));
105+    };
106+
107+    socket.addEventListener("open", onOpen, {
108+      once: true
109+    });
110+    socket.addEventListener("error", onError, {
111+      once: true
112+    });
113+  });
114+}
115+
116 test("start enters leader state and allows scheduler work only for the lease holder", async () => {
117   const heartbeatRequests = [];
118   const leaseRequests = [];
119@@ -588,6 +692,42 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
120   assert.equal(describePayload.ok, true);
121   assert.equal(describePayload.data.name, "baa-conductor-daemon");
122   assert.equal(describePayload.data.system.mode, "running");
123+  assert.equal(describePayload.data.describe_endpoints.business.path, "/describe/business");
124+  assert.equal(describePayload.data.describe_endpoints.control.path, "/describe/control");
125+
126+  const businessDescribeResponse = await handleConductorHttpRequest(
127+    {
128+      method: "GET",
129+      path: "/describe/business"
130+    },
131+    {
132+      repository,
133+      snapshotLoader: () => snapshot,
134+      version: "1.2.3"
135+    }
136+  );
137+  assert.equal(businessDescribeResponse.status, 200);
138+  const businessDescribePayload = parseJsonBody(businessDescribeResponse);
139+  assert.equal(businessDescribePayload.data.surface, "business");
140+  assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/tasks/u);
141+  assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/system\/pause/u);
142+
143+  const controlDescribeResponse = await handleConductorHttpRequest(
144+    {
145+      method: "GET",
146+      path: "/describe/control"
147+    },
148+    {
149+      repository,
150+      snapshotLoader: () => snapshot,
151+      version: "1.2.3"
152+    }
153+  );
154+  assert.equal(controlDescribeResponse.status, 200);
155+  const controlDescribePayload = parseJsonBody(controlDescribeResponse);
156+  assert.equal(controlDescribePayload.data.surface, "control");
157+  assert.match(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/system\/pause/u);
158+  assert.doesNotMatch(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/tasks/u);
159 
160   const healthResponse = await handleConductorHttpRequest(
161     {
162@@ -700,6 +840,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
163     }
164   );
165   assert.equal(pauseResponse.status, 200);
166+  assert.equal(parseJsonBody(pauseResponse).data.mode, "paused");
167   assert.equal((await repository.getAutomationState())?.mode, "paused");
168 
169   const systemStateResponse = await handleConductorHttpRequest(
170@@ -739,6 +880,7 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
171   const snapshot = await runtime.start();
172   assert.equal(snapshot.daemon.schedulerEnabled, true);
173   assert.match(snapshot.controlApi.localApiBase, /^http:\/\/127\.0\.0\.1:\d+$/u);
174+  assert.match(snapshot.controlApi.firefoxWsUrl, /^ws:\/\/127\.0\.0\.1:\d+\/ws\/firefox$/u);
175 
176   const baseUrl = snapshot.controlApi.localApiBase;
177 
178@@ -765,6 +907,7 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
179   const payload = await runtimeResponse.json();
180   assert.equal(payload.ok, true);
181   assert.equal(payload.data.identity, "mini-main@mini(primary)");
182+  assert.equal(payload.data.controlApi.firefoxWsUrl, snapshot.controlApi.firefoxWsUrl);
183   assert.equal(payload.data.controlApi.localApiBase, baseUrl);
184   assert.equal(payload.data.runtime.started, true);
185 
186@@ -786,6 +929,8 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
187     })
188   });
189   assert.equal(pauseResponse.status, 200);
190+  const pausePayload = await pauseResponse.json();
191+  assert.equal(pausePayload.data.mode, "paused");
192 
193   const pausedStateResponse = await fetch(`${baseUrl}/v1/system/state`);
194   const pausedStatePayload = await pausedStateResponse.json();
195@@ -797,6 +942,16 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
196   assert.equal(describePayload.ok, true);
197   assert.equal(describePayload.data.name, "baa-conductor-daemon");
198 
199+  const businessDescribeResponse = await fetch(`${baseUrl}/describe/business`);
200+  assert.equal(businessDescribeResponse.status, 200);
201+  const businessDescribePayload = await businessDescribeResponse.json();
202+  assert.equal(businessDescribePayload.data.surface, "business");
203+
204+  const controlDescribeResponse = await fetch(`${baseUrl}/describe/control`);
205+  assert.equal(controlDescribeResponse.status, 200);
206+  const controlDescribePayload = await controlDescribeResponse.json();
207+  assert.equal(controlDescribePayload.data.surface, "control");
208+
209   const stoppedSnapshot = await runtime.stop();
210   assert.equal(stoppedSnapshot.runtime.started, false);
211   rmSync(stateDir, {
212@@ -844,3 +999,120 @@ test("ConductorRuntime exposes a minimal runtime snapshot for CLI and status sur
213     recursive: true
214   });
215 });
216+
217+test("ConductorRuntime exposes a local Firefox websocket bridge over the local API listener", async () => {
218+  const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-firefox-ws-"));
219+  const runtime = new ConductorRuntime(
220+    {
221+      nodeId: "mini-main",
222+      host: "mini",
223+      role: "primary",
224+      controlApiBase: "https://control.example.test",
225+      localApiBase: "http://127.0.0.1:0",
226+      sharedToken: "replace-me",
227+      paths: {
228+        runsDir: "/tmp/runs",
229+        stateDir
230+      }
231+    },
232+    {
233+      autoStartLoops: false,
234+      now: () => 100
235+    }
236+  );
237+
238+  const snapshot = await runtime.start();
239+  const wsUrl = snapshot.controlApi.firefoxWsUrl;
240+  const baseUrl = snapshot.controlApi.localApiBase;
241+  const socket = new WebSocket(wsUrl);
242+  const queue = createWebSocketMessageQueue(socket);
243+
244+  await waitForWebSocketOpen(socket);
245+
246+  socket.send(
247+    JSON.stringify({
248+      type: "hello",
249+      clientId: "firefox-test",
250+      nodeType: "browser",
251+      nodeCategory: "proxy",
252+      nodePlatform: "firefox"
253+    })
254+  );
255+
256+  const helloAck = await queue.next((message) => message.type === "hello_ack");
257+  assert.equal(helloAck.clientId, "firefox-test");
258+  assert.equal(helloAck.wsUrl, wsUrl);
259+
260+  const initialSnapshot = await queue.next(
261+    (message) => message.type === "state_snapshot" && message.reason === "hello"
262+  );
263+  assert.equal(initialSnapshot.snapshot.system.mode, "running");
264+  assert.equal(initialSnapshot.snapshot.browser.client_count, 1);
265+
266+  const credentialRequest = await queue.next((message) => message.type === "request_credentials");
267+  assert.equal(credentialRequest.reason, "hello");
268+
269+  socket.send(
270+    JSON.stringify({
271+      type: "api_endpoints",
272+      platform: "chatgpt",
273+      endpoints: ["/backend-api/conversation", "/backend-api/models"]
274+    })
275+  );
276+  socket.send(
277+    JSON.stringify({
278+      type: "credentials",
279+      platform: "chatgpt",
280+      headers: {
281+        authorization: "Bearer test-token",
282+        cookie: "session=test"
283+      },
284+      timestamp: 1_760_000_000_000
285+    })
286+  );
287+
288+  const browserSnapshot = await queue.next(
289+    (message) =>
290+      message.type === "state_snapshot"
291+      && message.snapshot.browser.clients.some((client) =>
292+        client.client_id === "firefox-test"
293+        && client.credentials.some((entry) => entry.platform === "chatgpt" && entry.header_count === 2)
294+        && client.request_hooks.some((entry) => entry.platform === "chatgpt" && entry.endpoint_count === 2)
295+      )
296+  );
297+  assert.equal(browserSnapshot.snapshot.browser.client_count, 1);
298+
299+  socket.send(
300+    JSON.stringify({
301+      type: "action_request",
302+      action: "pause",
303+      requestId: "req-pause",
304+      requestedBy: "integration_test",
305+      reason: "pause_via_ws"
306+    })
307+  );
308+
309+  const actionResult = await queue.next(
310+    (message) => message.type === "action_result" && message.requestId === "req-pause"
311+  );
312+  assert.equal(actionResult.ok, true);
313+  assert.equal(actionResult.system.mode, "paused");
314+
315+  const pausedSnapshot = await queue.next(
316+    (message) => message.type === "state_snapshot" && message.snapshot.system.mode === "paused"
317+  );
318+  assert.equal(pausedSnapshot.snapshot.system.mode, "paused");
319+
320+  const systemStateResponse = await fetch(`${baseUrl}/v1/system/state`);
321+  assert.equal(systemStateResponse.status, 200);
322+  assert.equal((await systemStateResponse.json()).data.mode, "paused");
323+
324+  queue.stop();
325+  socket.close(1000, "done");
326+  const stoppedSnapshot = await runtime.stop();
327+  assert.equal(stoppedSnapshot.runtime.started, false);
328+  rmSync(stateDir, {
329+    force: true,
330+    recursive: true
331+  });
332+});
M apps/conductor-daemon/src/index.ts
+31, -2
  1@@ -11,6 +11,10 @@ import {
  2   type ConductorHttpRequest,
  3   type ConductorHttpResponse
  4 } from "./http-types.js";
  5+import {
  6+  ConductorFirefoxWebSocketServer,
  7+  buildFirefoxWebSocketUrl
  8+} from "./firefox-ws.js";
  9 import { handleConductorHttpRequest as handleConductorLocalHttpRequest } from "./local-api.js";
 10 import { ConductorLocalControlPlane } from "./local-control-plane.js";
 11 
 12@@ -156,6 +160,7 @@ export interface ConductorRuntimeSnapshot {
 13   paths: ConductorRuntimePaths;
 14   controlApi: {
 15     baseUrl: string;
 16+    firefoxWsUrl: string | null;
 17     localApiBase: string | null;
 18     hasSharedToken: boolean;
 19     usesPlaceholderToken: boolean;
 20@@ -529,7 +534,9 @@ async function readIncomingRequestBody(request: IncomingMessage): Promise<string
 21 }
 22 
 23 class ConductorLocalHttpServer {
 24+  private readonly firefoxWebSocketServer: ConductorFirefoxWebSocketServer;
 25   private readonly localApiBase: string;
 26+  private readonly now: () => number;
 27   private readonly repository: ControlPlaneRepository;
 28   private readonly snapshotLoader: () => ConductorRuntimeSnapshot;
 29   private readonly version: string | null;
 30@@ -540,19 +547,31 @@ class ConductorLocalHttpServer {
 31     localApiBase: string,
 32     repository: ControlPlaneRepository,
 33     snapshotLoader: () => ConductorRuntimeSnapshot,
 34-    version: string | null
 35+    version: string | null,
 36+    now: () => number
 37   ) {
 38     this.localApiBase = localApiBase;
 39+    this.now = now;
 40     this.repository = repository;
 41     this.snapshotLoader = snapshotLoader;
 42     this.version = version;
 43     this.resolvedBaseUrl = localApiBase;
 44+    this.firefoxWebSocketServer = new ConductorFirefoxWebSocketServer({
 45+      baseUrlLoader: () => this.resolvedBaseUrl,
 46+      now: this.now,
 47+      repository: this.repository,
 48+      snapshotLoader: this.snapshotLoader
 49+    });
 50   }
 51 
 52   getBaseUrl(): string {
 53     return this.resolvedBaseUrl;
 54   }
 55 
 56+  getFirefoxWebSocketUrl(): string | null {
 57+    return this.firefoxWebSocketServer.getUrl();
 58+  }
 59+
 60   async start(): Promise<string> {
 61     if (this.server != null) {
 62       return this.resolvedBaseUrl;
 63@@ -592,6 +611,9 @@ class ConductorLocalHttpServer {
 64         );
 65       });
 66     });
 67+    server.on("upgrade", (request, socket, head) => {
 68+      this.firefoxWebSocketServer.handleUpgrade(request, socket, head);
 69+    });
 70 
 71     await new Promise<void>((resolve, reject) => {
 72       const onError = (error: Error) => {
 73@@ -620,6 +642,7 @@ class ConductorLocalHttpServer {
 74 
 75     this.resolvedBaseUrl = formatLocalApiBaseUrl(address.address, (address as AddressInfo).port);
 76     this.server = server;
 77+    this.firefoxWebSocketServer.start();
 78     return this.resolvedBaseUrl;
 79   }
 80 
 81@@ -630,6 +653,7 @@ class ConductorLocalHttpServer {
 82 
 83     const server = this.server;
 84     this.server = null;
 85+    await this.firefoxWebSocketServer.stop();
 86 
 87     await new Promise<void>((resolve, reject) => {
 88       server.close((error) => {
 89@@ -1630,6 +1654,7 @@ function formatConfigText(config: ResolvedConductorRuntimeConfig): string {
 90     `identity: ${config.nodeId}@${config.host}(${config.role})`,
 91     `control_api_base: ${config.controlApiBase}`,
 92     `local_api_base: ${config.localApiBase ?? "not-configured"}`,
 93+    `firefox_ws_url: ${buildFirefoxWebSocketUrl(config.localApiBase) ?? "not-configured"}`,
 94     `local_api_allowed_hosts: ${config.localApiAllowedHosts.join(",") || "loopback-only"}`,
 95     `priority: ${config.priority}`,
 96     `preferred: ${String(config.preferred)}`,
 97@@ -1747,7 +1772,8 @@ export class ConductorRuntime {
 98             this.config.localApiBase,
 99             this.localControlPlane.repository,
100             () => this.getRuntimeSnapshot(),
101-            this.config.version
102+            this.config.version,
103+            this.now
104           );
105   }
106 
107@@ -1783,6 +1809,8 @@ export class ConductorRuntime {
108 
109   getRuntimeSnapshot(now: number = this.now()): ConductorRuntimeSnapshot {
110     const localApiBase = this.localApiServer?.getBaseUrl() ?? this.config.localApiBase;
111+    const firefoxWsUrl =
112+      this.localApiServer?.getFirefoxWebSocketUrl() ?? buildFirefoxWebSocketUrl(localApiBase);
113 
114     return {
115       daemon: this.daemon.getStatusSnapshot(now),
116@@ -1791,6 +1819,7 @@ export class ConductorRuntime {
117       paths: { ...this.config.paths },
118       controlApi: {
119         baseUrl: this.config.controlApiBase,
120+        firefoxWsUrl,
121         localApiBase,
122         hasSharedToken: this.config.sharedToken != null,
123         usesPlaceholderToken: usesPlaceholderToken(this.config.sharedToken)
M apps/conductor-daemon/src/local-api.ts
+270, -36
  1@@ -29,6 +29,7 @@ const TASK_STATUS_SET = new Set<TaskStatus>(TASK_STATUS_VALUES);
  2 
  3 type LocalApiRouteMethod = "GET" | "POST";
  4 type LocalApiRouteKind = "probe" | "read" | "write";
  5+type LocalApiDescribeSurface = "business" | "control";
  6 
  7 interface LocalApiRouteDefinition {
  8   id: string;
  9@@ -57,6 +58,7 @@ interface LocalApiRequestContext {
 10 export interface ConductorRuntimeApiSnapshot {
 11   controlApi: {
 12     baseUrl: string;
 13+    firefoxWsUrl?: string | null;
 14     hasSharedToken: boolean;
 15     localApiBase: string | null;
 16     usesPlaceholderToken: boolean;
 17@@ -138,7 +140,21 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
 18     kind: "read",
 19     method: "GET",
 20     pathPattern: "/describe",
 21-    summary: "读取完整自描述 JSON"
 22+    summary: "读取 describe 入口索引"
 23+  },
 24+  {
 25+    id: "service.describe.business",
 26+    kind: "read",
 27+    method: "GET",
 28+    pathPattern: "/describe/business",
 29+    summary: "读取业务类自描述 JSON"
 30+  },
 31+  {
 32+    id: "service.describe.control",
 33+    kind: "read",
 34+    method: "GET",
 35+    pathPattern: "/describe/control",
 36+    summary: "读取控制类自描述 JSON"
 37   },
 38   {
 39     id: "service.health",
 40@@ -294,14 +310,6 @@ function buildErrorEnvelope(requestId: string, error: LocalApiHttpError): Conduc
 41   return jsonResponse(error.status, payload, error.headers ?? {});
 42 }
 43 
 44-function buildAckResponse(requestId: string, summary: string): ConductorHttpResponse {
 45-  return buildSuccessEnvelope(requestId, 200, {
 46-    accepted: true,
 47-    status: "applied",
 48-    summary
 49-  });
 50-}
 51-
 52 function readBodyJson(request: ConductorHttpRequest): JsonValue | null {
 53   const rawBody = request.body?.trim() ?? "";
 54 
 55@@ -519,7 +527,7 @@ function extractAutomationMetadata(valueJson: string | null | undefined): {
 56   };
 57 }
 58 
 59-async function buildSystemStateData(repository: ControlPlaneRepository): Promise<JsonObject> {
 60+export async function buildSystemStateData(repository: ControlPlaneRepository): Promise<JsonObject> {
 61   const [automationState, lease, activeRuns, queuedTasks] = await Promise.all([
 62     repository.getAutomationState(),
 63     repository.getCurrentLease(),
 64@@ -563,6 +571,32 @@ async function buildSystemStateData(repository: ControlPlaneRepository): Promise
 65   };
 66 }
 67 
 68+function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonObject {
 69+  return {
 70+    auth_mode: "local_network_only",
 71+    enabled: snapshot.controlApi.firefoxWsUrl != null,
 72+    inbound_messages: [
 73+      "hello",
 74+      "state_request",
 75+      "action_request",
 76+      "credentials",
 77+      "api_endpoints",
 78+      "client_log"
 79+    ],
 80+    outbound_messages: [
 81+      "hello_ack",
 82+      "state_snapshot",
 83+      "action_result",
 84+      "request_credentials",
 85+      "error"
 86+    ],
 87+    path: "/ws/firefox",
 88+    purpose: "local Firefox extension bridge",
 89+    reconnect: "client auto-reconnect is expected and supported",
 90+    url: snapshot.controlApi.firefoxWsUrl ?? null
 91+  };
 92+}
 93+
 94 function describeRoute(route: LocalApiRouteDefinition): JsonObject {
 95   return {
 96     id: route.id,
 97@@ -575,8 +609,52 @@ function describeRoute(route: LocalApiRouteDefinition): JsonObject {
 98   };
 99 }
100 
101-function buildCapabilitiesData(snapshot: ConductorRuntimeApiSnapshot): JsonObject {
102-  const exposedRoutes = LOCAL_API_ROUTES.filter((route) => route.exposeInDescribe !== false);
103+function routeBelongsToSurface(
104+  route: LocalApiRouteDefinition,
105+  surface: LocalApiDescribeSurface
106+): boolean {
107+  if (route.exposeInDescribe === false || route.id === "service.describe") {
108+    return false;
109+  }
110+
111+  if (surface === "business") {
112+    return [
113+      "service.describe.business",
114+      "service.health",
115+      "service.version",
116+      "system.capabilities",
117+      "controllers.list",
118+      "tasks.list",
119+      "tasks.read",
120+      "tasks.logs.read",
121+      "runs.list",
122+      "runs.read"
123+    ].includes(route.id);
124+  }
125+
126+  return [
127+    "service.describe.control",
128+    "service.health",
129+    "service.version",
130+    "system.capabilities",
131+    "system.state",
132+    "system.pause",
133+    "system.resume",
134+    "system.drain",
135+    "probe.healthz",
136+    "probe.readyz",
137+    "probe.rolez",
138+    "probe.runtime"
139+  ].includes(route.id);
140+}
141+
142+function buildCapabilitiesData(
143+  snapshot: ConductorRuntimeApiSnapshot,
144+  surface: LocalApiDescribeSurface | "all" = "all"
145+): JsonObject {
146+  const exposedRoutes = LOCAL_API_ROUTES.filter((route) =>
147+    surface === "all" ? route.exposeInDescribe !== false : routeBelongsToSurface(route, surface)
148+  );
149 
150   return {
151     deployment_mode: "single-node mini",
152@@ -598,6 +676,13 @@ function buildCapabilitiesData(snapshot: ConductorRuntimeApiSnapshot): JsonObjec
153       lease_state: snapshot.daemon.leaseState,
154       scheduler_enabled: snapshot.daemon.schedulerEnabled,
155       started: snapshot.runtime.started
156+    },
157+    transports: {
158+      http: {
159+        auth_mode: "local_network_only",
160+        url: snapshot.controlApi.localApiBase ?? null
161+      },
162+      websocket: buildFirefoxWebSocketData(snapshot)
163     }
164   };
165 }
166@@ -622,8 +707,7 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
167   return buildSuccessEnvelope(context.requestId, 200, {
168     name: resolveServiceName(),
169     version,
170-    description:
171-      "BAA conductor local control surface backed directly by the mini node's local truth source.",
172+    description: "BAA conductor local API describe index. Read one of the scoped describe endpoints next.",
173     environment: {
174       summary: "single-node mini local daemon",
175       deployment_mode: "single-node mini",
176@@ -633,20 +717,49 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
177       origin
178     },
179     system,
180-    endpoints: LOCAL_API_ROUTES.filter((route) => route.exposeInDescribe !== false).map(describeRoute),
181+    websocket: buildFirefoxWebSocketData(snapshot),
182+    describe_endpoints: {
183+      business: {
184+        path: "/describe/business",
185+        summary: "业务查询入口;适合 CLI AI、网页版 AI、手机网页 AI 先读。"
186+      },
187+      control: {
188+        path: "/describe/control",
189+        summary: "控制动作入口;在 pause/resume/drain 前先读。"
190+      }
191+    },
192+    endpoints: [
193+      {
194+        method: "GET",
195+        path: "/describe/business"
196+      },
197+      {
198+        method: "GET",
199+        path: "/describe/control"
200+      }
201+    ],
202     capabilities: buildCapabilitiesData(snapshot),
203     examples: [
204       {
205-        title: "Read the full self-description first",
206+        title: "Read the business describe surface first",
207         method: "GET",
208-        path: "/describe",
209+        path: "/describe/business",
210         curl: buildCurlExample(
211           origin,
212-          LOCAL_API_ROUTES.find((route) => route.id === "service.describe")!
213+          LOCAL_API_ROUTES.find((route) => route.id === "service.describe.business")!
214         )
215       },
216       {
217-        title: "Inspect the narrower capability surface",
218+        title: "Read the control describe surface before any write",
219+        method: "GET",
220+        path: "/describe/control",
221+        curl: buildCurlExample(
222+          origin,
223+          LOCAL_API_ROUTES.find((route) => route.id === "service.describe.control")!
224+        )
225+      },
226+      {
227+        title: "Inspect the narrower capability surface if needed",
228         method: "GET",
229         path: "/v1/capabilities",
230         curl: buildCurlExample(
231@@ -672,9 +785,108 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
232       }
233     ],
234     notes: [
235+      "AI callers should prefer /describe/business for business queries and /describe/control for control actions.",
236       "These routes read and mutate the mini node's local truth source directly.",
237       "GET /healthz, /readyz, /rolez and /v1/runtime remain available as low-level diagnostics.",
238-      "This surface is intended to replace the old business-control reads previously served by control-api-worker."
239+      "The optional Firefox bridge WS reuses the same local listener and upgrades on /ws/firefox."
240+    ]
241+  });
242+}
243+
244+async function handleScopedDescribeRead(
245+  context: LocalApiRequestContext,
246+  version: string,
247+  surface: LocalApiDescribeSurface
248+): Promise<ConductorHttpResponse> {
249+  const repository = requireRepository(context.repository);
250+  const snapshot = context.snapshotLoader();
251+  const origin = snapshot.controlApi.localApiBase ?? "http://127.0.0.1";
252+  const system = await buildSystemStateData(repository);
253+  const routes = LOCAL_API_ROUTES.filter((route) => routeBelongsToSurface(route, surface));
254+
255+  if (surface === "business") {
256+    return buildSuccessEnvelope(context.requestId, 200, {
257+      name: resolveServiceName(),
258+      version,
259+      surface: "business",
260+      description: "Business describe surface for discovery-first AI callers.",
261+      audience: ["cli_ai", "web_ai", "mobile_web_ai"],
262+      environment: {
263+        deployment_mode: "single-node mini",
264+        truth_source: "local sqlite control plane",
265+        origin
266+      },
267+      recommended_flow: [
268+        "GET /describe/business",
269+        "Optionally GET /v1/capabilities",
270+        "Use read-only routes such as /v1/controllers, /v1/tasks and /v1/runs",
271+        "Do not assume /v1/exec, /v1/files/read, /v1/files/write or POST /v1/tasks are live unless explicitly listed"
272+      ],
273+      system,
274+      websocket: buildFirefoxWebSocketData(snapshot),
275+      endpoints: routes.map(describeRoute),
276+      examples: [
277+        {
278+          title: "List recent tasks",
279+          method: "GET",
280+          path: "/v1/tasks?limit=5",
281+          curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "tasks.list")!)
282+        },
283+        {
284+          title: "List recent runs",
285+          method: "GET",
286+          path: "/v1/runs?limit=5",
287+          curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "runs.list")!)
288+        }
289+      ],
290+      notes: [
291+        "This surface is intended to be enough for business-query discovery without reading external docs.",
292+        "Control actions are intentionally excluded; use /describe/control for pause/resume/drain."
293+      ]
294+    });
295+  }
296+
297+  return buildSuccessEnvelope(context.requestId, 200, {
298+    name: resolveServiceName(),
299+    version,
300+    surface: "control",
301+    description: "Control describe surface for state-first AI callers.",
302+    audience: ["cli_ai", "web_ai", "mobile_web_ai", "human_operator"],
303+    environment: {
304+      deployment_mode: "single-node mini",
305+      truth_source: "local sqlite control plane",
306+      origin
307+    },
308+    recommended_flow: [
309+      "GET /describe/control",
310+      "Optionally GET /v1/capabilities",
311+      "GET /v1/system/state",
312+      "Only then decide whether to call pause, resume or drain"
313+    ],
314+    system,
315+    websocket: buildFirefoxWebSocketData(snapshot),
316+    endpoints: routes.map(describeRoute),
317+    examples: [
318+      {
319+        title: "Read current system state first",
320+        method: "GET",
321+        path: "/v1/system/state",
322+        curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "system.state")!)
323+      },
324+      {
325+        title: "Pause local automation explicitly",
326+        method: "POST",
327+        path: "/v1/system/pause",
328+        curl: buildCurlExample(origin, LOCAL_API_ROUTES.find((route) => route.id === "system.pause")!, {
329+          requested_by: "human_operator",
330+          reason: "manual_pause",
331+          source: "local_control_surface"
332+        })
333+      }
334+    ],
335+    notes: [
336+      "This surface is intended to be enough for control discovery without reading external docs.",
337+      "Business queries such as tasks and runs are intentionally excluded; use /describe/business."
338     ]
339   });
340 }
341@@ -728,6 +940,32 @@ async function handleSystemStateRead(context: LocalApiRequestContext): Promise<C
342   );
343 }
344 
345+export interface SetAutomationModeInput {
346+  mode: AutomationMode;
347+  reason?: string | null;
348+  requestedBy?: string | null;
349+  source?: string | null;
350+  updatedAt: number;
351+}
352+
353+export async function setAutomationMode(
354+  repository: ControlPlaneRepository,
355+  input: SetAutomationModeInput
356+): Promise<JsonObject> {
357+  await repository.putSystemState({
358+    stateKey: AUTOMATION_STATE_KEY,
359+    updatedAt: input.updatedAt,
360+    valueJson: JSON.stringify({
361+      mode: input.mode,
362+      ...(input.requestedBy ? { requested_by: input.requestedBy } : {}),
363+      ...(input.reason ? { reason: input.reason } : {}),
364+      ...(input.source ? { source: input.source } : {})
365+    })
366+  });
367+
368+  return buildSystemStateData(repository);
369+}
370+
371 async function handleSystemMutation(
372   context: LocalApiRequestContext,
373   mode: AutomationMode
374@@ -738,24 +976,16 @@ async function handleSystemMutation(
375   const reason = readOptionalStringField(body, "reason");
376   const source = readOptionalStringField(body, "source");
377 
378-  await repository.putSystemState({
379-    stateKey: AUTOMATION_STATE_KEY,
380-    updatedAt: context.now(),
381-    valueJson: JSON.stringify({
382+  return buildSuccessEnvelope(
383+    context.requestId,
384+    200,
385+    await setAutomationMode(repository, {
386       mode,
387-      ...(requestedBy ? { requested_by: requestedBy } : {}),
388-      ...(reason ? { reason } : {}),
389-      ...(source ? { source } : {})
390+      reason,
391+      requestedBy,
392+      source,
393+      updatedAt: context.now()
394     })
395-  });
396-
397-  const suffix = [requestedBy ? `requested by ${requestedBy}` : null, reason ? `reason: ${reason}` : null]
398-    .filter((value) => value !== null)
399-    .join("; ");
400-
401-  return buildAckResponse(
402-    context.requestId,
403-    suffix === "" ? `Automation mode set to ${mode}.` : `Automation mode set to ${mode}; ${suffix}.`
404   );
405 }
406 
407@@ -892,6 +1122,10 @@ async function dispatchBusinessRoute(
408   switch (routeId) {
409     case "service.describe":
410       return handleDescribeRead(context, version);
411+    case "service.describe.business":
412+      return handleScopedDescribeRead(context, version, "business");
413+    case "service.describe.control":
414+      return handleScopedDescribeRead(context, version, "control");
415     case "service.health":
416       return handleHealthRead(context, version);
417     case "service.version":
M apps/conductor-daemon/src/node-shims.d.ts
+42, -0
 1@@ -1,9 +1,46 @@
 2+declare class Buffer extends Uint8Array {
 3+  static alloc(size: number): Buffer;
 4+  static allocUnsafe(size: number): Buffer;
 5+  static concat(chunks: readonly Uint8Array[]): Buffer;
 6+  static from(value: string, encoding?: string): Buffer;
 7+  copy(target: Uint8Array, targetStart?: number): number;
 8+  readBigUInt64BE(offset?: number): bigint;
 9+  readUInt16BE(offset?: number): number;
10+  subarray(start?: number, end?: number): Buffer;
11+  toString(encoding?: string): string;
12+  writeBigUInt64BE(value: bigint, offset?: number): number;
13+  writeUInt16BE(value: number, offset?: number): number;
14+}
15+
16+declare module "node:crypto" {
17+  export function createHash(
18+    algorithm: string
19+  ): {
20+    digest(encoding: string): string;
21+    update(value: string): { digest(encoding: string): string };
22+  };
23+
24+  export function randomUUID(): string;
25+}
26+
27 declare module "node:net" {
28   export interface AddressInfo {
29     address: string;
30     family: string;
31     port: number;
32   }
33+
34+  export interface Socket {
35+    destroy(error?: Error): this;
36+    destroySoon?(): void;
37+    end(chunk?: string | Uint8Array): this;
38+    on(event: "close", listener: () => void): this;
39+    on(event: "data", listener: (chunk: Buffer) => void): this;
40+    on(event: "end", listener: () => void): this;
41+    on(event: "error", listener: (error: Error) => void): this;
42+    setNoDelay(noDelay?: boolean): this;
43+    write(chunk: string | Uint8Array): boolean;
44+  }
45 }
46 
47 declare module "node:fs" {
48@@ -14,6 +51,7 @@ declare module "node:http" {
49   import type { AddressInfo } from "node:net";
50 
51   export interface IncomingMessage {
52+    headers: Record<string, string | string[] | undefined>;
53     method?: string;
54     on?(event: "data", listener: (chunk: string | Uint8Array) => void): this;
55     on?(event: "end", listener: () => void): this;
56@@ -33,6 +71,10 @@ declare module "node:http" {
57     close(callback?: (error?: Error) => void): this;
58     closeAllConnections?(): void;
59     listen(options: { host: string; port: number }): this;
60+    on(
61+      event: "upgrade",
62+      listener: (request: IncomingMessage, socket: import("node:net").Socket, head: Buffer) => void
63+    ): this;
64     off(event: "error", listener: (error: Error) => void): this;
65     off(event: "listening", listener: () => void): this;
66     off(event: string, listener: (...args: unknown[]) => void): this;
A coordination/tasks/T-C001.md
+92, -0
 1@@ -0,0 +1,92 @@
 2+---
 3+task_id: T-C001
 4+title: 本地 WS server
 5+status: review
 6+branch: feat/conductor-local-ws-server
 7+repo: /Users/george/code/baa-conductor
 8+base_ref: main@fdcc3fa
 9+depends_on: []
10+write_scope:
11+  - apps/conductor-daemon/**
12+  - docs/runtime/**
13+  - docs/api/**
14+updated_at: 2026-03-22
15+---
16+
17+# 本地 WS server
18+
19+## 目标
20+
21+在 `mini` 本地 `conductor-daemon` 中增加正式 WS server,承接本地 Firefox 插件双向通讯。
22+
23+## 本任务包含
24+
25+- 在 `conductor-daemon` 本地 HTTP server 上增加正式 WebSocket 入口
26+- 为 Firefox 插件定义并实现最小双向消息面
27+- 复用本地 control plane 状态和 `pause` / `resume` / `drain` 写接口
28+- 补最小 WS smoke / 测试
29+- 更新 runtime / API 文档
30+
31+## 本任务不包含
32+
33+- 不修改 Firefox 插件
34+- 不修改 Cloudflare Worker
35+- 不把该 WS 作为公网通道
36+
37+## 建议起始文件
38+
39+- `apps/conductor-daemon/src/index.ts`
40+- `apps/conductor-daemon/src/local-api.ts`
41+- `apps/conductor-daemon/src/index.test.js`
42+- `docs/runtime/environment.md`
43+- `docs/api/README.md`
44+
45+## 交付物
46+
47+- `conductor-daemon` 本地 Firefox WS server
48+- 对应消息模型和监听配置文档
49+- 已回写状态的任务卡
50+
51+## 验收
52+
53+- `conductor-daemon` typecheck / build / test 通过
54+- 本地最小 WS smoke 或测试通过
55+- `git diff --check` 通过
56+
57+## files_changed
58+
59+- `coordination/tasks/T-C001.md`
60+- `apps/conductor-daemon/src/firefox-ws.ts`
61+- `apps/conductor-daemon/src/index.ts`
62+- `apps/conductor-daemon/src/local-api.ts`
63+- `apps/conductor-daemon/src/node-shims.d.ts`
64+- `apps/conductor-daemon/src/index.test.js`
65+- `docs/api/README.md`
66+- `docs/api/firefox-local-ws.md`
67+- `docs/runtime/README.md`
68+- `docs/runtime/environment.md`
69+
70+## commands_run
71+
72+- `npx --yes pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon typecheck`
73+- `npx --yes pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
74+- `npx --yes pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon test`
75+- `git -C /Users/george/code/baa-conductor diff --check`
76+
77+## result
78+
79+- 在 `conductor-daemon` 现有本地 HTTP listener 上新增正式 Firefox WS bridge,固定 path 为 `/ws/firefox`
80+- 新增最小双向消息面:`hello`、`state_request`、`state_snapshot`、`action_request` / `action_result`、`credentials`、`api_endpoints`
81+- `pause` / `resume` / `drain` 的 HTTP 写接口改为直接返回最新 system state,WS action 复用同一套本地 control plane 写入逻辑
82+- runtime snapshot、`/describe`、`/v1/capabilities` 都补充了 Firefox WS URL 和消息面说明
83+- 增加 Node 集成测试,覆盖 WS 握手、browser metadata push 和通过 WS 执行 `pause`
84+
85+## risks
86+
87+- 当前只实现了 Firefox bridge 的最小结构;旧 browser-proxy 风格的 `api_request` / `api_response` 仍明确返回 `not_implemented`
88+- browser 侧 `credentials` 只在服务端保留最小元数据快照,当前没有把原始 header 落盘;如果后续需要真正代理请求,还要补更细的权限和生命周期设计
89+
90+## next_handoff
91+
92+- 如果后续确实要恢复 browser request proxy,再在现有 WS bridge 上继续实现 `api_request` / `api_response`
93+- 如果 Firefox 插件准备切到 WS action,可以直接对接当前 `action_request` / `action_result` 和 `state_snapshot`
M docs/api/README.md
+54, -5
  1@@ -2,6 +2,21 @@
  2 
  3 `baa-conductor` 当前把业务控制/查询接口收口到 `mini` 节点上的 `conductor-daemon` 本地 HTTP 面。
  4 
  5+## 给 AI 的阅读入口
  6+
  7+如果是 CLI AI、网页版 AI、手机网页 AI,推荐先按这个顺序阅读:
  8+
  9+1. [`business-interfaces.md`](./business-interfaces.md)
 10+2. [`control-interfaces.md`](./control-interfaces.md)
 11+3. 再回来看本文件做总览
 12+
 13+推荐使用方式:
 14+
 15+1. 先阅读业务类接口文档
 16+2. 再调 `GET /describe/business` 或 `GET /describe/control`
 17+3. 如有需要,再调 `GET /v1/capabilities`
 18+4. 完成能力感知后,再执行查询或控制动作
 19+
 20 原则:
 21 
 22 - `conductor-daemon` 本地 API 是这些业务接口的真相源
 23@@ -13,6 +28,7 @@
 24 | 服务 | 地址 | 说明 |
 25 | --- | --- | --- |
 26 | conductor-daemon local-api | `BAA_CONDUCTOR_LOCAL_API`,默认可用值如 `http://127.0.0.1:4317` | 本地真相源;承接 describe/health/version/capabilities/system/controllers/tasks/runs |
 27+| conductor-daemon local-firefox-ws | 由 `BAA_CONDUCTOR_LOCAL_API` 派生,例如 `ws://127.0.0.1:4317/ws/firefox` | 本地 Firefox 插件双向 bridge;复用同一个 listener,不单独开公网端口 |
 28 | status-api | `https://conductor.makefile.so` | 只读状态 JSON 和 HTML 视图 |
 29 | control-api | `https://control-api.makefile.so` | 仍可保留给遗留/远端控制面合同,但不再是下列业务接口的真相源 |
 30 
 31@@ -20,11 +36,17 @@
 32 
 33 推荐顺序:
 34 
 35-1. `GET ${BAA_CONDUCTOR_LOCAL_API}/describe`
 36-2. `GET ${BAA_CONDUCTOR_LOCAL_API}/v1/capabilities`
 37-3. `GET ${BAA_CONDUCTOR_LOCAL_API}/v1/system/state`
 38+1. `GET ${BAA_CONDUCTOR_LOCAL_API}/describe/business` 或 `GET ${BAA_CONDUCTOR_LOCAL_API}/describe/control`
 39+2. 如有需要,再看 `GET ${BAA_CONDUCTOR_LOCAL_API}/v1/capabilities`
 40+3. 如果是控制动作,再看 `GET ${BAA_CONDUCTOR_LOCAL_API}/v1/system/state`
 41 4. 按需查看 `controllers`、`tasks`、`runs`
 42 5. 只有在明确需要写操作时,再调用 `pause` / `resume` / `drain`
 43+6. 只有在明确需要浏览器双向通讯时,再手动连接 `/ws/firefox`
 44+
 45+如果是给 AI 写操作说明,优先引用:
 46+
 47+- [`business-interfaces.md`](./business-interfaces.md)
 48+- [`control-interfaces.md`](./control-interfaces.md)
 49 
 50 ## Conductor Daemon Local API
 51 
 52@@ -32,7 +54,9 @@
 53 
 54 | 方法 | 路径 | 说明 |
 55 | --- | --- | --- |
 56-| `GET` | `/describe` | 完整自描述 JSON,说明当前模式、真相源、端点、示例和注意事项 |
 57+| `GET` | `/describe` | describe 总入口索引,告诉调用方应该读哪个 scoped describe |
 58+| `GET` | `/describe/business` | 业务类自描述 JSON |
 59+| `GET` | `/describe/control` | 控制类自描述 JSON |
 60 | `GET` | `/health` | 服务健康摘要,带本地 system 状态 |
 61 | `GET` | `/version` | 轻量版本查询 |
 62 | `GET` | `/v1/capabilities` | 更窄的能力发现接口,区分读/写/诊断端点 |
 63@@ -46,6 +70,11 @@
 64 | `POST` | `/v1/system/resume` | 切到 `running` |
 65 | `POST` | `/v1/system/drain` | 切到 `draining` |
 66 
 67+写接口约定:
 68+
 69+- 成功时直接返回最新 system state,而不是只回一个 ack
 70+- 这样 HTTP 客户端和 WS `action_request` 都能复用同一份状态合同
 71+
 72 ### 只读业务接口
 73 
 74 | 方法 | 路径 | 说明 |
 75@@ -66,6 +95,26 @@
 76 | `GET` | `/rolez` | 当前 `leader` / `standby` 视图 |
 77 | `GET` | `/v1/runtime` | daemon runtime 快照 |
 78 
 79+## Firefox Local WS
 80+
 81+本地 Firefox bridge 复用 `conductor-daemon` 的同一个监听地址,不额外引入第二个端口:
 82+
 83+- HTTP base: `http://127.0.0.1:4317`
 84+- Firefox WS: `ws://127.0.0.1:4317/ws/firefox`
 85+
 86+当前约定:
 87+
 88+- 只服务本地 / loopback / 明确允许的 Tailscale `100.x` 地址
 89+- 不是公网通道,不对 Cloudflare Worker 暴露
 90+- Firefox 插件仍然默认走 HTTP control API;WS 只是手动启用的可选双向 bridge
 91+- `state_snapshot.system` 直接复用 `/v1/system/state` 的字段结构
 92+- `action_request` 支持 `pause` / `resume` / `drain`
 93+- 浏览器发来的 `credentials` / `api_endpoints` 只在服务端保存最小元数据并进入 snapshot,不回显原始 header 值
 94+
 95+详细消息模型和 smoke 示例见:
 96+
 97+- [`firefox-local-ws.md`](./firefox-local-ws.md)
 98+
 99 ## Status API
100 
101 `status-api` 仍是只读视图服务,不拥有真相。
102@@ -89,7 +138,7 @@ truth source:
103 
104 ```bash
105 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
106-curl "${LOCAL_API_BASE}/describe"
107+curl "${LOCAL_API_BASE}/describe/business"
108 ```
109 
110 ```bash
A docs/api/firefox-local-ws.md
+215, -0
  1@@ -0,0 +1,215 @@
  2+# Firefox Local WS
  3+
  4+`conductor-daemon` 现在在本地 HTTP listener 上正式支持 Firefox bridge WebSocket。
  5+
  6+目标:
  7+
  8+- 给本地 Firefox 插件一个正式、稳定、可重连的双向入口
  9+- 复用 `mini` 本地 control plane 的 system state / action write 能力
 10+- 不再把这条链路当成公网或 Cloudflare Worker 通道
 11+
 12+## 监听方式
 13+
 14+WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环境变量或第二个端口。
 15+
 16+例子:
 17+
 18+- `BAA_CONDUCTOR_LOCAL_API=http://127.0.0.1:4317` -> `ws://127.0.0.1:4317/ws/firefox`
 19+- `BAA_CONDUCTOR_LOCAL_API=http://100.71.210.78:4317` -> `ws://100.71.210.78:4317/ws/firefox`
 20+
 21+约束:
 22+
 23+- path 固定是 `/ws/firefox`
 24+- 只应监听 loopback 或显式允许的 Tailscale `100.x` 地址
 25+- 不是公网入口
 26+
 27+## 自动重连语义
 28+
 29+- Firefox 客户端断开后可以直接重连到同一个 URL
 30+- server 会接受新的连接并继续推送最新 snapshot
 31+- 如果同一个 `clientId` 建立了新连接,旧连接会被替换
 32+- `state_snapshot` 会在 `hello`、browser metadata 变化、system state 变化时重新推送
 33+
 34+## 消息模型
 35+
 36+### client -> server
 37+
 38+| `type` | 说明 |
 39+| --- | --- |
 40+| `hello` | 注册当前 Firefox client,声明 `clientId`、`nodeType`、`nodeCategory`、`nodePlatform` |
 41+| `state_request` | 主动请求最新 snapshot |
 42+| `action_request` | 请求执行 `pause` / `resume` / `drain` |
 43+| `credentials` | 上送浏览器凭证快照;server 只保留平台、header 数量、时间戳等最小元数据 |
 44+| `api_endpoints` | 上送当前 request hook 观察到的 endpoint 列表 |
 45+| `client_log` | 可选日志消息;当前 server 只接收,不做业务处理 |
 46+
 47+### server -> client
 48+
 49+| `type` | 说明 |
 50+| --- | --- |
 51+| `hello_ack` | 握手确认,回传 protocol/version、WS URL、支持的消息类型 |
 52+| `state_snapshot` | 当前 server/system/browser 摘要 |
 53+| `action_result` | `action_request` 的执行结果;成功时直接回传最新 `system` |
 54+| `request_credentials` | 提示浏览器重新发送 `credentials` |
 55+| `error` | 非法 JSON、未知消息类型或未实现消息 |
 56+
 57+## 关键 payload
 58+
 59+### `hello`
 60+
 61+```json
 62+{
 63+  "type": "hello",
 64+  "clientId": "firefox-ab12cd",
 65+  "nodeType": "browser",
 66+  "nodeCategory": "proxy",
 67+  "nodePlatform": "firefox"
 68+}
 69+```
 70+
 71+### `hello_ack`
 72+
 73+```json
 74+{
 75+  "type": "hello_ack",
 76+  "clientId": "firefox-ab12cd",
 77+  "protocol": "baa.firefox.local",
 78+  "version": 1,
 79+  "wsUrl": "ws://127.0.0.1:4317/ws/firefox",
 80+  "localApiBase": "http://127.0.0.1:4317",
 81+  "supports": {
 82+    "inbound": ["hello", "state_request", "action_request", "credentials", "api_endpoints", "client_log"],
 83+    "outbound": ["hello_ack", "state_snapshot", "action_result", "request_credentials", "error"]
 84+  }
 85+}
 86+```
 87+
 88+### `state_snapshot`
 89+
 90+```json
 91+{
 92+  "type": "state_snapshot",
 93+  "reason": "hello",
 94+  "snapshot": {
 95+    "version": 1,
 96+    "server": {
 97+      "identity": "mini-main@mini(primary)",
 98+      "local_api_base": "http://127.0.0.1:4317",
 99+      "ws_path": "/ws/firefox",
100+      "ws_url": "ws://127.0.0.1:4317/ws/firefox"
101+    },
102+    "system": {
103+      "mode": "running",
104+      "automation": {
105+        "mode": "running"
106+      },
107+      "leader": {
108+        "controller_id": "mini-main"
109+      },
110+      "queue": {
111+        "active_runs": 0,
112+        "queued_tasks": 0
113+      }
114+    },
115+    "browser": {
116+      "client_count": 1,
117+      "clients": [
118+        {
119+          "client_id": "firefox-ab12cd",
120+          "node_platform": "firefox",
121+          "credentials": [],
122+          "request_hooks": []
123+        }
124+      ]
125+    }
126+  }
127+}
128+```
129+
130+说明:
131+
132+- `snapshot.system` 直接复用 `GET /v1/system/state` 的合同
133+- `snapshot.browser.clients[].credentials` 只回传 `platform`、`header_count`、`captured_at`
134+- `snapshot.browser.clients[].request_hooks` 只回传 endpoint 列表和更新时间
135+
136+### `action_request`
137+
138+```json
139+{
140+  "type": "action_request",
141+  "requestId": "req-pause-1",
142+  "action": "pause",
143+  "requestedBy": "browser_admin",
144+  "reason": "human_clicked_pause",
145+  "source": "firefox_extension_ws"
146+}
147+```
148+
149+`action` 当前只允许:
150+
151+- `pause`
152+- `resume`
153+- `drain`
154+
155+成功响应:
156+
157+```json
158+{
159+  "type": "action_result",
160+  "requestId": "req-pause-1",
161+  "action": "pause",
162+  "ok": true,
163+  "system": {
164+    "mode": "paused"
165+  }
166+}
167+```
168+
169+### `credentials`
170+
171+```json
172+{
173+  "type": "credentials",
174+  "platform": "chatgpt",
175+  "headers": {
176+    "authorization": "Bearer ...",
177+    "cookie": "..."
178+  },
179+  "timestamp": 1760000000000
180+}
181+```
182+
183+server 行为:
184+
185+- 接收原始 header 作为浏览器凭证快照输入
186+- 不在 `state_snapshot` 中回显 header 原文
187+- 只把 `platform`、`header_count`、`captured_at` 汇总到 browser snapshot
188+
189+## 最小 smoke
190+
191+```bash
192+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
193+WS_URL="$(node --input-type=module -e 'const u = new URL(process.argv[1]); u.protocol = u.protocol === \"https:\" ? \"wss:\" : \"ws:\"; u.pathname = \"/ws/firefox\"; u.search = \"\"; u.hash = \"\"; console.log(u.toString());' "$LOCAL_API_BASE")"
194+
195+WS_URL="$WS_URL" node --input-type=module <<'EOF'
196+const socket = new WebSocket(process.env.WS_URL);
197+
198+socket.addEventListener("open", () => {
199+  socket.send(JSON.stringify({
200+    type: "hello",
201+    clientId: "smoke-client",
202+    nodeType: "browser",
203+    nodeCategory: "proxy",
204+    nodePlatform: "firefox"
205+  }));
206+
207+  socket.send(JSON.stringify({
208+    type: "state_request"
209+  }));
210+});
211+
212+socket.addEventListener("message", (event) => {
213+  console.log(event.data);
214+});
215+EOF
216+```
M docs/runtime/README.md
+7, -0
 1@@ -13,12 +13,19 @@
 2 
 3 - 长期运行节点只有 `mini`
 4 - canonical local API: `http://100.71.210.78:4317`
 5+- canonical local Firefox WS: `ws://100.71.210.78:4317/ws/firefox`
 6 - canonical public host: `https://conductor.makefile.so`
 7 - `status-api` `http://100.71.210.78:4318` 只作为本地只读观察面
 8 - `BAA_CONTROL_API_BASE` 仍在当前脚本里保留,但只是兼容变量,不是 canonical 主路径
 9 - 推荐仓库路径:`/Users/george/code/baa-conductor`
10 - repo 内的 plist 只作为模板;真正加载的是脚本渲染出来的安装副本
11 
12+Firefox WS 说明:
13+
14+- 不单独开新端口,直接复用 `BAA_CONDUCTOR_LOCAL_API`
15+- 固定 upgrade path 是 `/ws/firefox`
16+- 只给本地 Firefox 插件双向通讯使用,不是公网通道
17+
18 ## 最短路径
19 
20 1. `./scripts/runtime/install-mini.sh`
M docs/runtime/environment.md
+12, -0
 1@@ -3,6 +3,7 @@
 2 当前只保留 `mini` 单节点变量,默认口径如下:
 3 
 4 - canonical local API: `http://100.71.210.78:4317`
 5+- canonical local Firefox WS: `ws://100.71.210.78:4317/ws/firefox`
 6 - canonical public host: `https://conductor.makefile.so`
 7 - local status view: `http://100.71.210.78:4318`
 8 
 9@@ -35,6 +36,17 @@ BAA_CONTROL_API_BASE=https://control-api.makefile.so
10 
11 上面最后一项仍是当前兼容值;等 `status-api` 和 launchd 模板去掉 legacy 依赖后,应从默认运行路径删除。
12 
13+Firefox WS 派生规则:
14+
15+- 不新增 `BAA_CONDUCTOR_FIREFOX_WS` 之类单独变量
16+- 运行时直接从 `BAA_CONDUCTOR_LOCAL_API` 派生
17+- 公式固定为 `${BAA_CONDUCTOR_LOCAL_API}` 把 scheme 从 `http` 换成 `ws`,再把 path 设为 `/ws/firefox`
18+
19+例如:
20+
21+- `http://100.71.210.78:4317` -> `ws://100.71.210.78:4317/ws/firefox`
22+- `http://127.0.0.1:4317` -> `ws://127.0.0.1:4317/ws/firefox`
23+
24 ## 最小例子
25 
26 当前脚本仍要求保留兼容参数时,可这样安装: