- 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
+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+}
+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+});
+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)
+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":
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;
+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`
+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
+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+```
+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`
+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 当前脚本仍要求保留兼容参数时,可这样安装: