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