- commit
- 79345c9
- parent
- 818272a
- author
- im_wower
- date
- 2026-04-02 21:11:04 +0800 CST
feat: abstract local browser websocket bridge
9 files changed,
+292,
-94
Raw patch view.
1diff --git a/apps/conductor-daemon/src/firefox-ws.ts b/apps/conductor-daemon/src/firefox-ws.ts
2index 0190a19e5415f42475b68c58f243f59e70299f1b..f764b8a1574ef70b286124ce1775a68fafdc0acb 100644
3--- a/apps/conductor-daemon/src/firefox-ws.ts
4+++ b/apps/conductor-daemon/src/firefox-ws.ts
5@@ -38,8 +38,9 @@ import {
6 } from "./renewal/conversations.js";
7
8 const FIREFOX_WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
9+const BROWSER_WS_PROTOCOL = "baa.browser.local";
10 const FIREFOX_WS_PROTOCOL = "baa.firefox.local";
11-const FIREFOX_WS_PROTOCOL_VERSION = 1;
12+const BROWSER_WS_PROTOCOL_VERSION = 1;
13 const FIREFOX_WS_STATE_POLL_INTERVAL_MS = 2_000;
14 const FIREFOX_WS_LOGIN_STATE_STALE_AFTER_MS = 45_000;
15 const FIREFOX_WS_LOGIN_STATE_LOST_AFTER_MS = 120_000;
16@@ -50,6 +51,7 @@ const NORMAL_CLOSE_CODE = 1000;
17 const UNSUPPORTED_DATA_CLOSE_CODE = 1003;
18 const MESSAGE_TOO_LARGE_CLOSE_CODE = 1009;
19
20+export const BROWSER_WS_PATH = "/ws/browser";
21 export const FIREFOX_WS_PATH = "/ws/firefox";
22
23 type IntervalHandle = ReturnType<typeof globalThis.setInterval>;
24@@ -163,6 +165,33 @@ function normalizeNonEmptyString(value: unknown): string | null {
25 return normalized === "" ? null : normalized;
26 }
27
28+function inferNodePlatformFromClientId(clientId: string): string | null {
29+ const normalized = clientId.trim().toLowerCase();
30+
31+ if (normalized.startsWith("firefox-")) {
32+ return "firefox";
33+ }
34+
35+ if (normalized.startsWith("safari-")) {
36+ return "safari";
37+ }
38+
39+ return null;
40+}
41+
42+function resolveNodePlatform(input: {
43+ clientId: string;
44+ nodePlatform: string | null;
45+}): string | null {
46+ const explicit = normalizeNonEmptyString(input.nodePlatform);
47+
48+ if (explicit != null) {
49+ return explicit;
50+ }
51+
52+ return inferNodePlatformFromClientId(input.clientId);
53+}
54+
55 function readFirstString(
56 input: Record<string, unknown>,
57 keys: readonly string[]
58@@ -672,6 +701,21 @@ function buildFrame(opcode: number, payload: Buffer = Buffer.alloc(0)): Buffer {
59 return Buffer.concat([header, payload]);
60 }
61
62+export function buildBrowserWebSocketUrl(localApiBase: string | null | undefined): string | null {
63+ const normalized = normalizeNonEmptyString(localApiBase);
64+
65+ if (normalized == null) {
66+ return null;
67+ }
68+
69+ const url = new URL(normalized);
70+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
71+ url.pathname = BROWSER_WS_PATH;
72+ url.search = "";
73+ url.hash = "";
74+ return url.toString();
75+}
76+
77 export function buildFirefoxWebSocketUrl(localApiBase: string | null | undefined): string | null {
78 const normalized = normalizeNonEmptyString(localApiBase);
79
80@@ -1091,6 +1135,10 @@ export class ConductorFirefoxWebSocketServer {
81 }
82
83 getUrl(): string | null {
84+ return buildBrowserWebSocketUrl(this.baseUrlLoader());
85+ }
86+
87+ getFirefoxCompatUrl(): string | null {
88 return buildFirefoxWebSocketUrl(this.baseUrlLoader());
89 }
90
91@@ -1149,7 +1197,7 @@ export class ConductorFirefoxWebSocketServer {
92 handleUpgrade(request: IncomingMessage, socket: Socket, head: Buffer): boolean {
93 const pathname = normalizePathname(new URL(request.url ?? "/", "http://127.0.0.1").pathname);
94
95- if (pathname !== FIREFOX_WS_PATH) {
96+ if (pathname !== BROWSER_WS_PATH && pathname !== FIREFOX_WS_PATH) {
97 socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
98 socket.destroy();
99 return false;
100@@ -1276,7 +1324,7 @@ export class ConductorFirefoxWebSocketServer {
101 this.sendError(
102 connection,
103 "unsupported_message_type",
104- "api_request is a server-initiated Firefox bridge command and cannot be sent by the client."
105+ "api_request is a server-initiated browser bridge command and cannot be sent by the client."
106 );
107 return;
108 case "api_response":
109@@ -1314,7 +1362,10 @@ export class ConductorFirefoxWebSocketServer {
110 connection.setClientMetadata({
111 clientId,
112 nodeCategory: readFirstString(message, ["nodeCategory", "node_category"]),
113- nodePlatform: readFirstString(message, ["nodePlatform", "node_platform"]),
114+ nodePlatform: resolveNodePlatform({
115+ clientId,
116+ nodePlatform: readFirstString(message, ["nodePlatform", "node_platform"])
117+ }),
118 nodeType: readFirstString(message, ["nodeType", "node_type"])
119 });
120 this.connectionsByClientId.set(clientId, connection);
121@@ -1323,9 +1374,11 @@ export class ConductorFirefoxWebSocketServer {
122 type: "hello_ack",
123 clientId,
124 localApiBase: this.snapshotLoader().controlApi.localApiBase ?? null,
125- protocol: FIREFOX_WS_PROTOCOL,
126- version: FIREFOX_WS_PROTOCOL_VERSION,
127+ protocol: BROWSER_WS_PROTOCOL,
128+ protocol_compat: [FIREFOX_WS_PROTOCOL],
129+ version: BROWSER_WS_PROTOCOL_VERSION,
130 wsUrl: this.getUrl(),
131+ wsCompatUrls: [this.getFirefoxCompatUrl()].filter((entry): entry is string => entry != null),
132 supports: {
133 inbound: [
134 "hello",
135@@ -1570,7 +1623,10 @@ export class ConductorFirefoxWebSocketServer {
136 const runtime = this.snapshotLoader();
137 await this.repository.upsertBrowserLoginState({
138 account: summary.account,
139- browser: connection.session.nodePlatform ?? "firefox",
140+ browser:
141+ connection.session.nodePlatform
142+ ?? inferNodePlatformFromClientId(clientId)
143+ ?? "firefox",
144 capturedAt,
145 clientId,
146 credentialFingerprint: fingerprint,
147@@ -2108,11 +2164,11 @@ export class ConductorFirefoxWebSocketServer {
148 scheduler_enabled: runtime.daemon.schedulerEnabled,
149 started: runtime.runtime.started,
150 started_at: toUnixMilliseconds(runtime.runtime.startedAt),
151- ws_path: FIREFOX_WS_PATH,
152+ ws_path: BROWSER_WS_PATH,
153 ws_url: this.getUrl()
154 },
155 system: await buildSystemStateData(this.repository),
156- version: FIREFOX_WS_PROTOCOL_VERSION
157+ version: BROWSER_WS_PROTOCOL_VERSION
158 };
159 }
160
161@@ -2145,7 +2201,7 @@ export class ConductorFirefoxWebSocketServer {
162 recent_executes: [],
163 recent_ingests: []
164 },
165- ws_path: FIREFOX_WS_PATH,
166+ ws_path: BROWSER_WS_PATH,
167 ws_url: this.getUrl()
168 };
169 }
170diff --git a/apps/conductor-daemon/src/index.test.js b/apps/conductor-daemon/src/index.test.js
171index eef834882b46e5d18c389835464ce25e7b45e5b0..ace17e4a8faeb88c0aae503da739a3ca4c90adb9 100644
172--- a/apps/conductor-daemon/src/index.test.js
173+++ b/apps/conductor-daemon/src/index.test.js
174@@ -228,6 +228,7 @@ async function createLocalApiFixture(options = {}) {
175 },
176 controlApi: {
177 baseUrl: "https://control.example.test",
178+ browserWsUrl: "ws://127.0.0.1:4317/ws/browser",
179 firefoxWsUrl: "ws://127.0.0.1:4317/ws/firefox",
180 hasSharedToken: true,
181 localApiBase: "http://127.0.0.1:4317",
182@@ -2248,8 +2249,8 @@ function createBrowserBridgeStub() {
183 shell_runtime: [buildShellRuntime("claude")]
184 }
185 ],
186- ws_path: "/ws/firefox",
187- ws_url: "ws://127.0.0.1:4317/ws/firefox"
188+ ws_path: "/ws/browser",
189+ ws_url: "ws://127.0.0.1:4317/ws/browser"
190 };
191
192 const buildApiResponse = ({ body, clientId = "firefox-claude", error = null, id = null, status = 200 }) => ({
193@@ -2817,7 +2818,7 @@ async function fetchJson(url, init) {
194 };
195 }
196
197-async function connectFirefoxBridgeClient(wsUrl, clientId) {
198+async function connectBrowserBridgeClient(wsUrl, clientId, nodePlatform = "firefox") {
199 const socket = new WebSocket(wsUrl);
200 const queue = createWebSocketMessageQueue(socket);
201
202@@ -2828,7 +2829,7 @@ async function connectFirefoxBridgeClient(wsUrl, clientId) {
203 clientId,
204 nodeType: "browser",
205 nodeCategory: "proxy",
206- nodePlatform: "firefox"
207+ nodePlatform
208 })
209 );
210
211@@ -2851,6 +2852,10 @@ async function connectFirefoxBridgeClient(wsUrl, clientId) {
212 };
213 }
214
215+async function connectFirefoxBridgeClient(wsUrl, clientId) {
216+ return await connectBrowserBridgeClient(wsUrl, clientId, "firefox");
217+}
218+
219 test("writeHttpResponse stops waiting for body backpressure when the client closes", async () => {
220 const response = new MockWritableResponse((_chunk, writeCount, writableResponse) => {
221 assert.equal(writeCount, 1);
222@@ -6922,7 +6927,7 @@ test("handleConductorHttpRequest returns browser_request_timeout with timeout de
223 assert.equal(payload.details.bridge_request_id, "browser-timeout-1");
224 });
225
226-test("handleConductorHttpRequest returns a clear 503 for Claude browser actions without an active Firefox client", async () => {
227+test("handleConductorHttpRequest returns a clear 503 for Claude browser actions without an active browser client", async () => {
228 const { repository, snapshot } = await createLocalApiFixture();
229
230 const response = await handleConductorHttpRequest(
231@@ -6936,8 +6941,8 @@ test("handleConductorHttpRequest returns a clear 503 for Claude browser actions
232 active_connection_id: null,
233 client_count: 0,
234 clients: [],
235- ws_path: "/ws/firefox",
236- ws_url: snapshot.controlApi.firefoxWsUrl
237+ ws_path: "/ws/browser",
238+ ws_url: snapshot.controlApi.browserWsUrl
239 }),
240 repository,
241 snapshotLoader: () => snapshot
242@@ -7618,6 +7623,7 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
243 assert.equal(snapshot.daemon.schedulerEnabled, true);
244 assert.equal(snapshot.codexd.localApiBase, codexd.baseUrl);
245 assert.match(snapshot.controlApi.localApiBase, /^http:\/\/127\.0\.0\.1:\d+$/u);
246+ assert.match(snapshot.controlApi.browserWsUrl, /^ws:\/\/127\.0\.0\.1:\d+\/ws\/browser$/u);
247 assert.match(snapshot.controlApi.firefoxWsUrl, /^ws:\/\/127\.0\.0\.1:\d+\/ws\/firefox$/u);
248
249 const baseUrl = snapshot.controlApi.localApiBase;
250@@ -7649,6 +7655,7 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
251 const payload = await runtimeResponse.json();
252 assert.equal(payload.ok, true);
253 assert.equal(payload.data.identity, "mini-main@mini(primary)");
254+ assert.equal(payload.data.controlApi.browserWsUrl, snapshot.controlApi.browserWsUrl);
255 assert.equal(payload.data.controlApi.firefoxWsUrl, snapshot.controlApi.firefoxWsUrl);
256 assert.equal(payload.data.controlApi.localApiBase, baseUrl);
257 assert.equal(payload.data.runtime.started, true);
258@@ -8240,6 +8247,7 @@ test("ConductorRuntime awaits daemon shutdown when local API startup fails", asy
259 },
260 stop: async () => {},
261 getBaseUrl: () => "http://127.0.0.1:0",
262+ getBrowserWebSocketUrl: () => null,
263 getFirefoxWebSocketUrl: () => null
264 };
265
266@@ -8298,6 +8306,7 @@ test("ConductorRuntime.stop waits for daemon shutdown before closing the local A
267 localApiStopSawDaemonResolved = daemonStopResolved;
268 },
269 getBaseUrl: () => "http://127.0.0.1:0",
270+ getBrowserWebSocketUrl: () => null,
271 getFirefoxWebSocketUrl: () => null
272 };
273
274@@ -8465,7 +8474,9 @@ test("ConductorRuntime exposes a local Firefox websocket bridge over the local A
275
276 const helloAck = await queue.next((message) => message.type === "hello_ack");
277 assert.equal(helloAck.clientId, "firefox-test");
278- assert.equal(helloAck.wsUrl, wsUrl);
279+ assert.equal(helloAck.protocol, "baa.browser.local");
280+ assert.equal(helloAck.wsUrl, snapshot.controlApi.browserWsUrl);
281+ assert.deepEqual(helloAck.wsCompatUrls, [snapshot.controlApi.firefoxWsUrl]);
282
283 const initialSnapshot = await queue.next(
284 (message) => message.type === "state_snapshot" && message.reason === "hello"
285@@ -8576,6 +8587,43 @@ test("ConductorRuntime exposes a local Firefox websocket bridge over the local A
286 });
287 });
288
289+test("ConductorRuntime accepts Safari bridge clients on the canonical browser websocket path", async () => {
290+ await withRuntimeFixture(async ({ runtime }) => {
291+ let client = null;
292+
293+ try {
294+ const snapshot = await runtime.start();
295+ const wsUrl = snapshot.controlApi.browserWsUrl;
296+ const baseUrl = snapshot.controlApi.localApiBase;
297+
298+ client = await connectBrowserBridgeClient(wsUrl, "safari-test", "safari");
299+
300+ assert.equal(client.helloAck.clientId, "safari-test");
301+ assert.equal(client.helloAck.protocol, "baa.browser.local");
302+ assert.equal(client.helloAck.wsUrl, snapshot.controlApi.browserWsUrl);
303+ assert.deepEqual(client.helloAck.wsCompatUrls, [snapshot.controlApi.firefoxWsUrl]);
304+ assert.equal(client.initialSnapshot.snapshot.browser.clients[0]?.node_platform, "safari");
305+ assert.equal(client.initialSnapshot.snapshot.browser.ws_path, "/ws/browser");
306+
307+ const browserStatus = await fetch(`${baseUrl}/v1/browser`);
308+ assert.equal(browserStatus.status, 200);
309+ const browserPayload = await browserStatus.json();
310+ assert.equal(browserPayload.data.bridge.transport, "local_browser_ws");
311+ assert.equal(browserPayload.data.bridge.ws_path, "/ws/browser");
312+ assert.equal(browserPayload.data.bridge.ws_url, snapshot.controlApi.browserWsUrl);
313+ assert.equal(browserPayload.data.current_client.client_id, "safari-test");
314+ assert.equal(browserPayload.data.current_client.node_platform, "safari");
315+ } finally {
316+ client?.queue.stop();
317+ client?.socket.close();
318+
319+ if (client?.socket) {
320+ await waitForWebSocketClose(client.socket);
321+ }
322+ }
323+ });
324+});
325+
326 test("ConductorRuntime exposes Firefox outbound bridge commands and api request responses", async () => {
327 const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-firefox-bridge-"));
328 const runtime = new ConductorRuntime(
329@@ -9992,6 +10040,7 @@ test("persistent live ingest survives restart and /v1/browser restores recent hi
330 },
331 controlApi: {
332 baseUrl: "https://control.example.test",
333+ browserWsUrl: "ws://127.0.0.1:4317/ws/browser",
334 firefoxWsUrl: "ws://127.0.0.1:4317/ws/firefox",
335 hasSharedToken: true,
336 localApiBase: "http://127.0.0.1:4317",
337diff --git a/apps/conductor-daemon/src/index.ts b/apps/conductor-daemon/src/index.ts
338index 8bbf934e0f7899725ae43847d55546a8734c8bbf..bab2759a5ccc256b450d272bc6c3e52a4ad1c733 100644
339--- a/apps/conductor-daemon/src/index.ts
340+++ b/apps/conductor-daemon/src/index.ts
341@@ -28,6 +28,7 @@ import {
342 type ConductorHttpResponse
343 } from "./http-types.js";
344 import {
345+ buildBrowserWebSocketUrl,
346 ConductorFirefoxWebSocketServer,
347 buildFirefoxWebSocketUrl
348 } from "./firefox-ws.js";
349@@ -252,6 +253,7 @@ export interface ConductorRuntimeSnapshot {
350 paths: ConductorRuntimePaths;
351 controlApi: {
352 baseUrl: string;
353+ browserWsUrl: string | null;
354 firefoxWsUrl: string | null;
355 localApiBase: string | null;
356 hasSharedToken: boolean;
357@@ -848,6 +850,10 @@ class ConductorLocalHttpServer {
358 }
359
360 getFirefoxWebSocketUrl(): string | null {
361+ return this.firefoxWebSocketServer.getFirefoxCompatUrl();
362+ }
363+
364+ getBrowserWebSocketUrl(): string | null {
365 return this.firefoxWebSocketServer.getUrl();
366 }
367
368@@ -2165,6 +2171,7 @@ function formatConfigText(config: ResolvedConductorRuntimeConfig): string {
369 `codexd_local_api_base: ${config.codexdLocalApiBase ?? "not-configured"}`,
370 `code_root_dir: ${config.codeRootDir}`,
371 `local_api_base: ${config.localApiBase ?? "not-configured"}`,
372+ `browser_ws_url: ${buildBrowserWebSocketUrl(config.localApiBase) ?? "not-configured"}`,
373 `firefox_ws_url: ${buildFirefoxWebSocketUrl(config.localApiBase) ?? "not-configured"}`,
374 `local_api_allowed_hosts: ${config.localApiAllowedHosts.join(",") || "loopback-only"}`,
375 `priority: ${config.priority}`,
376@@ -2436,6 +2443,8 @@ export class ConductorRuntime {
377
378 getRuntimeSnapshot(now: number = this.now()): ConductorRuntimeSnapshot {
379 const localApiBase = this.localApiServer?.getBaseUrl() ?? this.config.localApiBase;
380+ const browserWsUrl =
381+ this.localApiServer?.getBrowserWebSocketUrl() ?? buildBrowserWebSocketUrl(localApiBase);
382 const firefoxWsUrl =
383 this.localApiServer?.getFirefoxWebSocketUrl() ?? buildFirefoxWebSocketUrl(localApiBase);
384
385@@ -2446,6 +2455,7 @@ export class ConductorRuntime {
386 paths: { ...this.config.paths },
387 controlApi: {
388 baseUrl: this.config.publicApiBase,
389+ browserWsUrl,
390 firefoxWsUrl,
391 localApiBase,
392 hasSharedToken: this.config.sharedToken != null,
393diff --git a/apps/conductor-daemon/src/local-api.ts b/apps/conductor-daemon/src/local-api.ts
394index fb8af94b1e6d72cc91dfb4b9ea6e3b35593da64e..790cd00e4b738e8dc82055eea71ca20248747ad3 100644
395--- a/apps/conductor-daemon/src/local-api.ts
396+++ b/apps/conductor-daemon/src/local-api.ts
397@@ -320,6 +320,7 @@ export interface ConductorRuntimeApiSnapshot {
398 };
399 controlApi: {
400 baseUrl: string;
401+ browserWsUrl?: string | null;
402 firefoxWsUrl?: string | null;
403 hasSharedToken: boolean;
404 localApiBase: string | null;
405@@ -576,7 +577,7 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
406 lifecycle: "legacy",
407 method: "POST",
408 pathPattern: "/v1/browser/claude/send",
409- summary: "legacy 包装:通过本地 Firefox bridge 发起一轮 Claude 对话"
410+ summary: "legacy 包装:通过本地 browser bridge 发起一轮 Claude 对话"
411 },
412 {
413 id: "browser.claude.current",
414@@ -593,7 +594,7 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
415 lifecycle: "legacy",
416 method: "POST",
417 pathPattern: "/v1/browser/chatgpt/send",
418- summary: "legacy 包装:通过本地 Firefox bridge 发起一轮 ChatGPT 对话"
419+ summary: "legacy 包装:通过本地 browser bridge 发起一轮 ChatGPT 对话"
420 },
421 {
422 id: "browser.chatgpt.current",
423@@ -610,7 +611,7 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
424 lifecycle: "legacy",
425 method: "POST",
426 pathPattern: "/v1/browser/gemini/send",
427- summary: "legacy 包装:通过本地 Firefox bridge 发起一轮 Gemini 对话"
428+ summary: "legacy 包装:通过本地 browser bridge 发起一轮 Gemini 对话"
429 },
430 {
431 id: "browser.gemini.current",
432@@ -2139,8 +2140,8 @@ function createEmptyBrowserState(snapshot: ConductorRuntimeApiSnapshot): Browser
433 lastSession: null
434 },
435 instruction_ingest: createEmptyBrowserInstructionIngestSnapshot(),
436- ws_path: "/ws/firefox",
437- ws_url: snapshot.controlApi.firefoxWsUrl ?? null
438+ ws_path: "/ws/browser",
439+ ws_url: snapshot.controlApi.browserWsUrl ?? snapshot.controlApi.firefoxWsUrl ?? null
440 };
441 }
442
443@@ -2538,14 +2539,14 @@ function createBrowserBridgeHttpError(action: string, error: unknown): LocalApiH
444 return new LocalApiHttpError(
445 503,
446 "browser_bridge_unavailable",
447- "No active Firefox bridge client is connected.",
448+ "No active browser bridge client is connected.",
449 details
450 );
451 case "client_not_found":
452 return new LocalApiHttpError(
453 409,
454 "browser_client_not_found",
455- "The requested Firefox bridge client is not connected.",
456+ "The requested browser bridge client is not connected.",
457 details
458 );
459 case "duplicate_request_id":
460@@ -2585,14 +2586,14 @@ function createBrowserBridgeHttpError(action: string, error: unknown): LocalApiH
461 return new LocalApiHttpError(
462 503,
463 "browser_bridge_unavailable",
464- `The Firefox bridge became unavailable while processing ${action}.`,
465+ `The browser bridge became unavailable while processing ${action}.`,
466 details
467 );
468 default:
469 return new LocalApiHttpError(
470 502,
471 "browser_bridge_error",
472- `Failed to execute ${action} through the Firefox bridge.`,
473+ `Failed to execute ${action} through the browser bridge.`,
474 details
475 );
476 }
477@@ -2675,14 +2676,14 @@ function ensureBrowserClientReady(
478 throw new LocalApiHttpError(
479 503,
480 "browser_bridge_unavailable",
481- `No active Firefox bridge client is connected for ${platform} requests.`
482+ `No active browser bridge client is connected for ${platform} requests.`
483 );
484 }
485
486 throw new LocalApiHttpError(
487 409,
488 "browser_client_not_found",
489- `Firefox bridge client "${normalizedRequestedClientId}" is not connected.`,
490+ `Browser bridge client "${normalizedRequestedClientId}" is not connected.`,
491 compactJsonObject({
492 client_id: normalizedRequestedClientId,
493 platform
494@@ -2698,7 +2699,7 @@ function ensureClaudeBridgeReady(
495 throw new LocalApiHttpError(
496 503,
497 "browser_bridge_unavailable",
498- "No active Firefox bridge client is connected for Claude actions."
499+ "No active browser bridge client is connected for Claude actions."
500 );
501 }
502
503@@ -2706,7 +2707,7 @@ function ensureClaudeBridgeReady(
504 throw new LocalApiHttpError(
505 409,
506 "claude_credentials_unavailable",
507- "Claude credentials are not available yet on the selected Firefox bridge client.",
508+ "Claude credentials are not available yet on the selected browser bridge client.",
509 compactJsonObject({
510 client_id: selection.client.client_id,
511 requested_client_id: normalizeOptionalString(requestedClientId),
512@@ -3771,7 +3772,7 @@ async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<
513 client_count: browserState.client_count,
514 clients: browserState.clients.map(serializeBrowserClientSnapshot),
515 status: browserState.client_count > 0 ? "connected" : "disconnected",
516- transport: "local_firefox_ws",
517+ transport: "local_browser_ws",
518 ws_path: browserState.ws_path,
519 ws_url: browserState.ws_url
520 },
521@@ -4066,9 +4067,11 @@ async function requestCodexd(
522 }
523
524 function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonObject {
525+ const canonicalUrl = snapshot.controlApi.browserWsUrl ?? snapshot.controlApi.firefoxWsUrl ?? null;
526+
527 return {
528 auth_mode: "local_network_only",
529- enabled: snapshot.controlApi.firefoxWsUrl != null,
530+ enabled: canonicalUrl != null,
531 inbound_messages: [
532 "hello",
533 "state_request",
534@@ -4100,10 +4103,12 @@ function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonO
535 "reload",
536 "error"
537 ],
538- path: "/ws/firefox",
539- purpose: "local Firefox extension bridge",
540+ compat_paths: ["/ws/firefox"],
541+ compat_url: snapshot.controlApi.firefoxWsUrl ?? null,
542+ path: "/ws/browser",
543+ purpose: "local browser extension bridge",
544 reconnect: "client auto-reconnect is expected and supported",
545- url: snapshot.controlApi.firefoxWsUrl ?? null
546+ url: canonicalUrl
547 };
548 }
549
550@@ -4115,7 +4120,7 @@ function buildBrowserActionContract(origin: string): JsonObject {
551 "必填字符串。当前正式支持 plugin_status、request_credentials、tab_open、tab_focus、tab_reload、tab_restore、ws_reconnect、controller_reload。",
552 platform:
553 "tab_open、tab_focus、tab_reload、request_credentials、tab_restore 建议带非空平台字符串;当前正式 shell / credential 管理平台已覆盖 claude 和 chatgpt,Gemini 仍留在下一波。",
554- clientId: "可选字符串;指定目标 Firefox bridge client。",
555+ clientId: "可选字符串;指定目标 browser bridge client。",
556 reason: "可选字符串;request_credentials、tab_reload、tab_restore、ws_reconnect、controller_reload 会原样透传给浏览器侧。",
557 disconnectMs:
558 `仅 ws_reconnect 可选;断开后保持离线的毫秒数。支持 disconnect_ms / delayMs / delay_ms 别名;范围 0-${MAX_BROWSER_WS_RECONNECT_DISCONNECT_MS}。`,
559@@ -4155,7 +4160,7 @@ function buildBrowserActionContract(origin: string): JsonObject {
560 }
561 ],
562 error_semantics: [
563- "503 browser_bridge_unavailable: 当前没有可用 Firefox bridge client。",
564+ "503 browser_bridge_unavailable: 当前没有可用 browser bridge client。",
565 "409 browser_client_not_found: 指定的 clientId 当前未连接。",
566 "504 browser_action_timeout: 插件未在约定时间内回传结构化 action_result。"
567 ]
568@@ -4168,7 +4173,7 @@ function buildBrowserRequestContract(origin: string): JsonObject {
569 request_body: {
570 platform:
571 "必填字符串;当前正式支持 claude、chatgpt 和 gemini。claude 额外支持省略 path + prompt 的兼容模式;chatgpt / gemini 当前只支持显式 path 的 raw proxy 请求。",
572- clientId: "可选字符串;指定目标 Firefox bridge client。",
573+ clientId: "可选字符串;指定目标 browser bridge client。",
574 requestId: "可选字符串;用于 trace 和未来 cancel 对齐。缺省时由 conductor 生成。",
575 method: "可选字符串;默认 GET。若携带 requestBody 或 prompt 且未显式指定,则默认 POST。",
576 path: "raw proxy 模式下必填;直接转发给浏览器本地 HTTP 代理路径。",
577@@ -4222,7 +4227,7 @@ function buildBrowserRequestContract(origin: string): JsonObject {
578 error_semantics: [
579 "400 invalid_request: 缺字段或组合不合法;例如既没有 path,也不是 platform=claude + prompt 的兼容模式。",
580 "409 claude_credentials_unavailable: Claude prompt 模式还没有捕获到可用凭证。",
581- "503 browser_bridge_unavailable: 当前没有活跃 Firefox bridge client。",
582+ "503 browser_bridge_unavailable: 当前没有活跃 browser bridge client。",
583 "4xx/5xx browser_upstream_error: 浏览器本地代理已返回上游 HTTP 错误;responseMode=sse 时会在事件流里交付 stream_error。"
584 ]
585 };
586@@ -4238,12 +4243,12 @@ function buildBrowserRequestCancelContract(): JsonObject {
587 reason: "可选字符串;取消原因。"
588 },
589 current_state: "active",
590- implementation_status: "会把 cancel 请求转发给当前执行中的 Firefox bridge client,并让原始 request 尽快以取消错误结束。",
591+ implementation_status: "会把 cancel 请求转发给当前执行中的 browser bridge client,并让原始 request 尽快以取消错误结束。",
592 error_semantics: [
593 "400 invalid_request: requestId 或 platform 缺失。",
594 "404 browser_request_not_found: 对应 requestId 当前不在执行中。",
595 "409 browser_request_client_mismatch: 指定了 clientId,但与实际执行中的 client 不一致。",
596- "503 browser_bridge_unavailable: 当前执行中的 Firefox bridge client 已断开。"
597+ "503 browser_bridge_unavailable: 当前执行中的 browser bridge client 已断开。"
598 ]
599 };
600 }
601@@ -4263,7 +4268,7 @@ function buildBrowserLegacyRouteData(): JsonObject[] {
602
603 function buildBrowserHttpData(snapshot: ConductorRuntimeApiSnapshot, origin: string): JsonObject {
604 return {
605- enabled: snapshot.controlApi.firefoxWsUrl != null,
606+ enabled: (snapshot.controlApi.browserWsUrl ?? snapshot.controlApi.firefoxWsUrl) != null,
607 legacy_helper_platform: BROWSER_CLAUDE_PLATFORM,
608 legacy_helper_platforms: [
609 BROWSER_CLAUDE_PLATFORM,
610@@ -4286,12 +4291,12 @@ function buildBrowserHttpData(snapshot: ConductorRuntimeApiSnapshot, origin: str
611 legacy_routes: buildBrowserLegacyRouteData(),
612 transport: {
613 http: snapshot.controlApi.localApiBase ?? null,
614- websocket: snapshot.controlApi.firefoxWsUrl ?? null
615+ websocket: snapshot.controlApi.browserWsUrl ?? snapshot.controlApi.firefoxWsUrl ?? null
616 },
617 notes: [
618 "Business-facing browser work now lands on POST /v1/browser/request; browser/plugin management lands on POST /v1/browser/actions.",
619 "GET /v1/browser remains the shared read model for login-state metadata, plugin connectivity, shell_runtime, and the latest structured action_result per client.",
620- "The generic browser HTTP request surface now formally supports Claude prompt/raw relay plus ChatGPT and Gemini raw relay, and expects a local Firefox bridge client.",
621+ "The generic browser HTTP request surface now formally supports Claude prompt/raw relay plus ChatGPT and Gemini raw relay, and expects a local browser bridge client.",
622 "Claude keeps the prompt shortcut when path is omitted; ChatGPT and Gemini require an explicit path and a real browser login context captured on the selected client.",
623 "Gemini raw relay can additionally use conversationId to prefer a conversation-matched persisted send template when the plugin has one.",
624 "The legacy helper surface now also exposes /v1/browser/chatgpt/* and /v1/browser/gemini/* wrappers for BAA target compatibility.",
625@@ -4809,7 +4814,7 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
626 "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN>; missing or wrong tokens return 401 JSON.",
627 "These routes read and mutate the mini node's local truth source directly.",
628 "GET /healthz, /readyz, /rolez and /v1/runtime remain available as low-level diagnostics.",
629- "The optional Firefox bridge WS reuses the same local listener and upgrades on /ws/firefox."
630+ "The optional browser bridge WS reuses the same local listener and upgrades on /ws/browser, with /ws/firefox kept as a compatibility path."
631 ]
632 });
633 }
634@@ -5983,7 +5988,7 @@ async function handleBrowserRequestCancel(context: LocalApiRequestContext): Prom
635 throw new LocalApiHttpError(
636 409,
637 "browser_request_client_mismatch",
638- `Browser request "${requestId}" is not running on the requested Firefox bridge client.`,
639+ `Browser request "${requestId}" is not running on the requested browser bridge client.`,
640 compactJsonObject({
641 client_id: clientId,
642 platform,
643diff --git a/docs/api/README.md b/docs/api/README.md
644index c36e7831b6a005551bac9024bc63a3df572c9b76..d372ce7594643fb09b127dafeaf91119ce66aba6 100644
645--- a/docs/api/README.md
646+++ b/docs/api/README.md
647@@ -31,7 +31,7 @@
648 | conductor public host | `https://conductor.makefile.so` | 唯一公网入口;VPS Nginx 回源到同一个 `conductor-daemon` local-api |
649 | conductor-daemon local-api | `BAA_CONDUCTOR_LOCAL_API`,默认可用值如 `http://127.0.0.1:4317` | 本地真相源;承接 describe/health/version/capabilities/status/browser/system/controllers/tasks/codex/host-ops |
650 | codexd local-api | `BAA_CODEXD_LOCAL_API_BASE`,默认可用值如 `http://127.0.0.1:4319` | 独立 `codexd` 本地服务;支持 `GET /describe` 自描述;`conductor-daemon` 的 `/v1/codex/*` 只代理到这里 |
651-| conductor-daemon local-firefox-ws | 由 `BAA_CONDUCTOR_LOCAL_API` 派生,例如 `ws://127.0.0.1:4317/ws/firefox` | 本地 Firefox 插件双向 bridge;复用同一个 listener,不单独开公网端口 |
652+| conductor-daemon local-browser-ws | 由 `BAA_CONDUCTOR_LOCAL_API` 派生,例如 `ws://127.0.0.1:4317/ws/browser` | 本地 browser 插件双向 bridge;复用同一个 listener,不单独开公网端口,`/ws/firefox` 保留兼容路径 |
653 | status-api local view | `http://127.0.0.1:4318` | 本地只读状态兼容包装层;继续提供 `/describe`、`/v1/status`、`/v1/status/ui` 和 `/ui`,默认从 `BAA_CONDUCTOR_LOCAL_API` 的 `/v1/system/state` 取数,不承担公网入口角色 |
654
655 ## Describe First
656@@ -45,7 +45,7 @@
657 5. 按需查看 `browser`、`controllers`、`tasks`、`codex`
658 6. 如果要做本机 shell / 文件操作,先读 `GET ${BAA_CONDUCTOR_LOCAL_API}/describe/control` 返回里的 `host_operations`,并准备 `Authorization: Bearer <BAA_SHARED_TOKEN>`
659 7. 只有在明确需要写操作时,再调用 `pause` / `resume` / `drain` 或 `host-ops`
660-8. 只有在明确需要浏览器双向通讯时,再手动连接 `/ws/firefox`
661+8. 只有在明确需要浏览器双向通讯时,再手动连接 `/ws/browser`
662
663 如果是直接调用 `codexd`:
664
665@@ -134,7 +134,7 @@
666
667 | 方法 | 路径 | 说明 |
668 | --- | --- | --- |
669-| `GET` | `/v1/browser` | 读取活跃 Firefox bridge、插件在线状态、最新 `shell_runtime` / `last_action_result`、持久化登录态记录和 `fresh` / `stale` / `lost` 状态 |
670+| `GET` | `/v1/browser` | 读取活跃 browser bridge、插件在线状态、最新 `shell_runtime` / `last_action_result`、持久化登录态记录和 `fresh` / `stale` / `lost` 状态 |
671 | `POST` | `/v1/browser/actions` | 派发通用 browser/plugin 管理动作;当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore` |
672 | `POST` | `/v1/browser/request` | 发起通用 browser HTTP 代发请求;当前正式支持 Claude 的 prompt/raw buffered / SSE 请求,以及 ChatGPT 的 path-based buffered / SSE 请求 |
673 | `POST` | `/v1/browser/request/cancel` | 取消请求或流;会向对应 Firefox client 派发 `request_cancel` |
674@@ -159,10 +159,10 @@ Browser 面约定:
675 - SSE 响应固定用 `stream_open`、`stream_event`、`stream_end`、`stream_error` 作为 event name;`stream_event` 带递增 `seq`
676 - `POST /v1/browser/actions` 当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore`,并返回结构化 `action_result`
677 - `GET /v1/browser` 会回显当前风控默认值、最新 `shell_runtime` / `last_action_result` 和运行时 target/platform 状态,便于观察抖动、限流、退避和熔断
678-- `/ws/firefox` 只在本地 listener 上可用,不是公网产品接口
679-- `request` 的 Claude prompt 模式和 `current` 辅助读只有在 `mini` 上已有活跃 Firefox bridge client,且 Claude 页面已捕获有效凭证和 endpoint 时才可用
680+- `/ws/browser` 只在本地 listener 上可用,不是公网产品接口;`/ws/firefox` 仅作兼容路径
681+- `request` 的 Claude prompt 模式和 `current` 辅助读只有在 `mini` 上已有活跃 browser bridge client,且 Claude 页面已捕获有效凭证和 endpoint 时才可用
682 - ChatGPT / Gemini raw relay 同样依赖真实浏览器里已捕获到的有效登录态 / header;建议先用 `GET /v1/browser?platform=chatgpt&status=fresh`、`GET /v1/browser?platform=gemini&status=fresh` 确认再发请求
683-- 如果当前没有活跃 Firefox client,会返回清晰的 `503` JSON 错误
684+- 如果当前没有活跃 browser client,会返回清晰的 `503` JSON 错误
685 - 如果已连接 client 还没拿到 Claude 凭证,会返回 `409` JSON 错误并提示先在浏览器里完成一轮真实请求
686
687 ### 最小 Claude Curl
688@@ -316,18 +316,19 @@ host-ops 约定:
689 | `GET` | `/rolez` | 当前 `leader` / `standby` 视图 |
690 | `GET` | `/v1/runtime` | daemon runtime 快照 |
691
692-## Firefox Local WS
693+## Browser Local WS
694
695-本地 Firefox bridge 复用 `conductor-daemon` 的同一个监听地址,不额外引入第二个端口:
696+本地 browser bridge 复用 `conductor-daemon` 的同一个监听地址,不额外引入第二个端口:
697
698 - HTTP base: `http://127.0.0.1:4317`
699-- Firefox WS: `ws://127.0.0.1:4317/ws/firefox`
700+- Browser WS: `ws://127.0.0.1:4317/ws/browser`
701+- Firefox compat WS: `ws://127.0.0.1:4317/ws/firefox`
702
703 当前约定:
704
705 - 只服务本地 / loopback / 明确允许的 Tailscale `100.x` 地址
706 - 不是公网通道,不单独暴露到 `conductor.makefile.so`
707-- Firefox 插件默认同时使用本地 WS 和同一个本地 HTTP listener
708+- Firefox / Safari 插件默认同时使用本地 WS 和同一个本地 HTTP listener
709 - `state_snapshot.system` 直接复用 `/v1/system/state` 的字段结构
710 - `action_request` 支持 `pause` / `resume` / `drain`
711 - 浏览器发来的 `credentials` / `api_endpoints` 会被转换成 `account`、凭证指纹、端点元数据和 `fresh/stale/lost` 持久化记录
712diff --git a/docs/api/business-interfaces.md b/docs/api/business-interfaces.md
713index a141e4687f970ba1237fc51301e86704effb0cd6..068ea2ed8a118a31773a94cade892e9135d83eec 100644
714--- a/docs/api/business-interfaces.md
715+++ b/docs/api/business-interfaces.md
716@@ -69,15 +69,15 @@
717
718 | 方法 | 路径 | 作用 |
719 | --- | --- | --- |
720-| `POST` | `/v1/browser/request` | 通过 `conductor -> /ws/firefox -> 插件页面内 HTTP 代理` 发起通用 browser request;当前正式支持 Claude 的 prompt/raw buffered / SSE 请求,以及 ChatGPT 的 path-based buffered / SSE 请求 |
721-| `POST` | `/v1/browser/request/cancel` | 取消 request 或流;会向对应 Firefox client 下发正式 `request_cancel` |
722+| `POST` | `/v1/browser/request` | 通过 `conductor -> /ws/browser -> 插件页面内 HTTP 代理` 发起通用 browser request;当前正式支持 Claude 的 prompt/raw buffered / SSE 请求,以及 ChatGPT 的 path-based buffered / SSE 请求 |
723+| `POST` | `/v1/browser/request/cancel` | 取消 request 或流;会向对应 browser client 下发正式 `request_cancel` |
724 | `POST` | `/v1/browser/claude/send` | legacy 包装:等价映射到 `POST /v1/browser/request` |
725
726 说明:
727
728 - `GET /v1/browser` 是当前正式浏览器桥接读面;支持按 `platform`、`account`、`client_id`、`host`、`status` 过滤
729 - 这个读面只返回 `account`、凭证指纹、端点元数据和时间戳状态;不会暴露原始 `cookie`、`token` 或 header 值
730-- `GET /v1/browser` 现在会额外返回 `automation_conversations`,按 `platform + remote_conversation_id` 暴露当前对话的 `automation_status`、`pause_reason` 和 active link,供 Firefox 浮层和调试读面同步统一自动化状态
731+- `GET /v1/browser` 现在会额外返回 `automation_conversations`,按 `platform + remote_conversation_id` 暴露当前对话的 `automation_status`、`pause_reason` 和 active link,供 Firefox / Safari 浮层和调试读面同步统一自动化状态
732 - `records[].view` 会区分活跃连接与仅持久化记录,`status` 会暴露 `fresh`、`stale`、`lost`
733 - 当前浏览器代发面正式支持 `claude`、`chatgpt` 和 `gemini`
734 - `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式;`platform=chatgpt` / `platform=gemini` 当前都必须显式带 `path`
735@@ -100,7 +100,7 @@
736 - `repeated_renewal_count`
737 - `conductor` 启动时会自动释放异常退出遗留的 `execution_state`,并把 `status=running` 的 renewal job 安全回排为 `pending`
738 - ChatGPT / Gemini raw relay 仍依赖浏览器里真实捕获到的登录态 / header;建议先看 `GET /v1/browser?platform=chatgpt&status=fresh`、`GET /v1/browser?platform=gemini&status=fresh`
739-- 如果没有活跃 Firefox bridge client,会返回 `503`
740+- 如果没有活跃 browser bridge client,会返回 `503`
741 - 如果 client 还没有 Claude 凭证快照,会返回 `409`
742 - 打开、聚焦、重载标签页等 browser/plugin 管理动作已经移到 [`control-interfaces.md`](./control-interfaces.md) 和 `GET /describe/control`
743
744diff --git a/docs/api/control-interfaces.md b/docs/api/control-interfaces.md
745index eb72fef2b821396567bc49718f21c5f8fa2d1a6e..0decd63642d27de3e3bcf57438d3e087637fea7c 100644
746--- a/docs/api/control-interfaces.md
747+++ b/docs/api/control-interfaces.md
748@@ -69,7 +69,7 @@
749
750 | 方法 | 路径 | 作用 |
751 | --- | --- | --- |
752-| `GET` | `/v1/browser` | 返回 Firefox bridge 在线状态、插件摘要、最新 `shell_runtime` / `last_action_result`、浏览器登录态持久化记录,以及浮层同步所需的 `automation_conversations` |
753+| `GET` | `/v1/browser` | 返回 browser bridge 在线状态、插件摘要、最新 `shell_runtime` / `last_action_result`、浏览器登录态持久化记录,以及浮层同步所需的 `automation_conversations` |
754 | `GET` | `/v1/system/state` | 返回当前 automation mode、leader、queue、active runs |
755
756 ### Browser / Plugin 管理
757@@ -83,7 +83,7 @@
758 browser/plugin 管理约定:
759
760 - `GET /v1/browser` 是 plugin 状态和登录态元数据的共享读面;不是单独的第三层 describe
761-- `GET /v1/browser` 现在也会暴露 `automation_conversations`,把当前 active link 对应的 `automation_status`、`pause_reason` 和 `remote_conversation_id` 提供给 Firefox 浮层同步
762+- `GET /v1/browser` 现在也会暴露 `automation_conversations`,把当前 active link 对应的 `automation_status`、`pause_reason` 和 `remote_conversation_id` 提供给 Firefox / Safari 浮层同步
763 - `POST /v1/browser/actions` 当前正式支持:
764 - `request_credentials`
765 - `tab_open`
766@@ -98,7 +98,7 @@ browser/plugin 管理约定:
767 - `repeatCount` / `repeat_count`
768 - `repeatIntervalMs` / `repeat_interval_ms` / `intervalMs` / `interval_ms`
769 - 当前正式 shell / credential 管理平台仍覆盖 `claude` 和 `chatgpt`;Gemini raw relay 已转入 business 面,但 control 面的 shell 管理合同没有在这轮一起扩面
770-- 如果没有活跃 Firefox bridge client,会返回 `503`
771+- 如果没有活跃 browser bridge client,会返回 `503`
772 - 如果指定了不存在的 `clientId`,会返回 `409`
773 - `POST /v1/browser/actions` 会等待插件回传结构化 `action_result`,返回 `accepted` / `completed` / `failed` / `reason` / `target` / `result` / `shell_runtime`
774 - 对 `browser.proxy_delivery` 一类动作,`action_result.results[*]` 现在可额外带 `delivery_ack`,用于表达下游确认层级;首版已稳定提供 Level 1 `status_code`
775@@ -137,7 +137,7 @@ system 控制约定:
776 - `GET /v1/renewal/jobs?local_conversation_id=...`
777 - `paused` 不会删除任务,只会阻止 dispatcher 继续推进待执行 job
778 - `manual` 和 `auto` / `paused` 共用同一份 `local_conversations` 后端状态,不存在插件侧单独影子开关
779-- renewal REST 写接口修改状态后,Firefox bridge 的 `state_snapshot.browser.automation_conversations` 会在下一轮 WS 推送中同步更新,供浮层实时刷新
780+- renewal REST 写接口修改状态后,browser bridge 的 `state_snapshot.browser.automation_conversations` 会在下一轮 WS 推送中同步更新,供浮层实时刷新
781 - conductor 启动时会自动清理上次异常退出遗留的 `execution_state`,并把 `status=running` 的 renewal job 重新排回 `pending`
782 - `GET /v1/renewal/conversations/:local_conversation_id` 现在会额外暴露:
783 - `pause_reason`
784diff --git a/docs/api/firefox-local-ws.md b/docs/api/firefox-local-ws.md
785index 53b4895930b48e84c1a378ab2525cb287eb20e3e..da4f6996a4f4aa82adb8482160827fc08fdcbc27 100644
786--- a/docs/api/firefox-local-ws.md
787+++ b/docs/api/firefox-local-ws.md
788@@ -1,10 +1,10 @@
789-# Firefox Local WS
790+# Browser Local WS
791
792-`conductor-daemon` 现在在本地 HTTP listener 上正式支持 Firefox bridge WebSocket。
793+`conductor-daemon` 现在在本地 HTTP listener 上正式支持 browser bridge WebSocket。
794
795 目标:
796
797-- 给本地 Firefox 插件一个正式、稳定、可重连的双向入口
798+- 给本地 Firefox / Safari 插件一个正式、稳定、可重连的双向入口
799 - 复用 `mini` 本地 control plane 的 system state / action write 能力
800 - 不再把这条链路当成公网或 Cloudflare Worker 通道
801
802@@ -14,19 +14,20 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
803
804 例子:
805
806-- `BAA_CONDUCTOR_LOCAL_API=http://100.71.210.78:4317` -> `ws://100.71.210.78:4317/ws/firefox`
807-- `BAA_CONDUCTOR_LOCAL_API=http://127.0.0.1:4317` -> `ws://127.0.0.1:4317/ws/firefox`
808+- `BAA_CONDUCTOR_LOCAL_API=http://100.71.210.78:4317` -> `ws://100.71.210.78:4317/ws/browser`
809+- `BAA_CONDUCTOR_LOCAL_API=http://127.0.0.1:4317` -> `ws://127.0.0.1:4317/ws/browser`
810
811 约束:
812
813-- path 固定是 `/ws/firefox`
814+- canonical path 是 `/ws/browser`
815+- `/ws/firefox` 仍保留为兼容路径
816 - 只应监听 loopback 或显式允许的 Tailscale `100.x` 地址
817 - 不是公网入口
818 - 当前正式浏览器代发 HTTP 面只支持 `claude`;但 `GET /v1/browser` 的元数据读面可以返回所有已上报 `platform`
819
820 ## 自动重连语义
821
822-- Firefox 客户端断开后可以直接重连到同一个 URL
823+- browser 客户端断开后可以直接重连到同一个 URL
824 - server 会接受新的连接并继续推送最新 snapshot
825 - 如果同一个 `clientId` 建立了新连接,旧连接会被替换
826 - `state_snapshot` 会在 `hello`、browser metadata 变化、system state 变化时重新推送
827@@ -37,7 +38,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
828
829 | `type` | 说明 |
830 | --- | --- |
831-| `hello` | 注册当前 Firefox client,声明 `clientId`、`nodeType`、`nodeCategory`、`nodePlatform` |
832+| `hello` | 注册当前 browser client,声明 `clientId`、`nodeType`、`nodeCategory`、`nodePlatform` |
833 | `state_request` | 主动请求最新 snapshot |
834 | `action_request` | 请求执行 `pause` / `resume` / `drain` |
835 | `action_result` | 回传 browser/plugin 管理动作的结构化执行结果;带 `accepted` / `completed` / `failed` / `reason` / `result` / `shell_runtime` |
836@@ -73,10 +74,10 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
837 ```json
838 {
839 "type": "hello",
840- "clientId": "firefox-ab12cd",
841+ "clientId": "safari-ab12cd",
842 "nodeType": "browser",
843 "nodeCategory": "proxy",
844- "nodePlatform": "firefox"
845+ "nodePlatform": "safari"
846 }
847 ```
848
849@@ -85,10 +86,12 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
850 ```json
851 {
852 "type": "hello_ack",
853- "clientId": "firefox-ab12cd",
854- "protocol": "baa.firefox.local",
855+ "clientId": "safari-ab12cd",
856+ "protocol": "baa.browser.local",
857+ "protocol_compat": ["baa.firefox.local"],
858 "version": 1,
859- "wsUrl": "ws://100.71.210.78:4317/ws/firefox",
860+ "wsUrl": "ws://100.71.210.78:4317/ws/browser",
861+ "wsCompatUrls": ["ws://100.71.210.78:4317/ws/firefox"],
862 "localApiBase": "http://100.71.210.78:4317",
863 "supports": {
864 "inbound": ["hello", "state_request", "action_request", "action_result", "credentials", "api_endpoints", "client_log", "browser.final_message", "api_response", "stream_open", "stream_event", "stream_end", "stream_error"],
865@@ -108,8 +111,8 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
866 "server": {
867 "identity": "mini-main@mini(primary)",
868 "local_api_base": "http://100.71.210.78:4317",
869- "ws_path": "/ws/firefox",
870- "ws_url": "ws://100.71.210.78:4317/ws/firefox"
871+ "ws_path": "/ws/browser",
872+ "ws_url": "ws://100.71.210.78:4317/ws/browser"
873 },
874 "system": {
875 "mode": "running",
876@@ -142,8 +145,8 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
877 "client_count": 1,
878 "clients": [
879 {
880- "client_id": "firefox-ab12cd",
881- "node_platform": "firefox",
882+ "client_id": "safari-ab12cd",
883+ "node_platform": "safari",
884 "credentials": [],
885 "final_messages": [],
886 "request_hooks": []
887@@ -159,7 +162,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
888 - `snapshot.system` 直接复用 `GET /v1/system/state` 的合同
889 - `snapshot.browser.clients[].credentials` 只回传 `account`、`credential_fingerprint`、`freshness`、`header_count` 和时间戳
890 - `snapshot.browser.clients[].final_messages` 只保留当前活跃 bridge client 最近观测到的最终消息,不写入当前持久化表
891-- `snapshot.browser.automation_conversations` 会按 active link 暴露当前页面/对话的 `automation_status`、`pause_reason`、`local_conversation_id` 和 `remote_conversation_id`,供 Firefox 浮层同步统一自动化状态
892+- `snapshot.browser.automation_conversations` 会按 active link 暴露当前页面/对话的 `automation_status`、`pause_reason`、`local_conversation_id` 和 `remote_conversation_id`,供 Firefox / Safari 浮层同步统一自动化状态
893 - `snapshot.browser.instruction_ingest` 暴露 live ingest / execute 的持久化读面:
894 - `last_ingest`
895 - `last_execute`
896@@ -494,7 +497,7 @@ SSE 请求示例:
897
898 说明:
899
900-- 可显式指定 `clientId` 目标;未指定时 server 会选最近活跃的 Firefox client
901+- 可显式指定 `clientId` 目标;未指定时 server 会选最近活跃的 browser client
902 - 当前插件按 `platform` 激活或拉起对应页面
903
904 ### `request_credentials`
905@@ -569,7 +572,7 @@ SSE 请求示例:
906
907 当前非目标:
908
909-- 不把 `/ws/firefox` 直接暴露成公网产品接口
910+- 不把 `/ws/browser` 或兼容路径 `/ws/firefox` 直接暴露成公网产品接口
911 - 不把页面对话 UI、聊天 DOM 自动化或多标签会话编排写成正式 bridge 能力
912 - 正式 HTTP 面已经收口到 `GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`;`/v1/browser/claude/*` 只保留 legacy 包装与 Claude 辅助读
913 - 本文仍只讨论 WS transport、client registry 和 request-response 基础能力
914@@ -580,7 +583,7 @@ SSE 请求示例:
915
916 ```bash
917 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://100.71.210.78:4317}"
918-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")"
919+WS_URL="$(node --input-type=module -e 'const u = new URL(process.argv[1]); u.protocol = u.protocol === \"https:\" ? \"wss:\" : \"ws:\"; u.pathname = \"/ws/browser\"; u.search = \"\"; u.hash = \"\"; console.log(u.toString());' "$LOCAL_API_BASE")"
920
921 WS_URL="$WS_URL" node --input-type=module <<'EOF'
922 const socket = new WebSocket(process.env.WS_URL);
923@@ -588,10 +591,10 @@ const socket = new WebSocket(process.env.WS_URL);
924 socket.addEventListener("open", () => {
925 socket.send(JSON.stringify({
926 type: "hello",
927- clientId: "smoke-client",
928+ clientId: "safari-smoke-client",
929 nodeType: "browser",
930 nodeCategory: "proxy",
931- nodePlatform: "firefox"
932+ nodePlatform: "safari"
933 }));
934
935 socket.send(JSON.stringify({
936@@ -607,7 +610,7 @@ EOF
937
938 ### 端到端 Claude HTTP smoke
939
940-如果要验证 `conductor HTTP -> /ws/firefox -> Claude 页面内 HTTP 代理` 这条最小闭环,直接运行:
941+如果要验证 `conductor HTTP -> /ws/browser -> Claude 页面内 HTTP 代理` 这条最小闭环,直接运行:
942
943 ```bash
944 ./scripts/runtime/browser-control-e2e-smoke.sh
945@@ -623,4 +626,4 @@ EOF
946 - `POST /v1/browser/request`
947 - `POST /v1/browser/request/cancel`
948 - 正式 SSE 与 Claude legacy wrapper
949-- 以及 `/ws/firefox` 上的 `open_tab`、`request_cancel`、`api_request` / `api_response` / `stream_*`
950+- 以及 `/ws/browser` 上的 `open_tab`、`request_cancel`、`api_request` / `api_response` / `stream_*`
951diff --git a/tests/browser/browser-control-e2e-smoke.test.mjs b/tests/browser/browser-control-e2e-smoke.test.mjs
952index 7b93d28967dd877abf8fd9e14838adadf0f76bd5..14671eed1b14e4d064c2f04b9ed6dc94e795c605 100644
953--- a/tests/browser/browser-control-e2e-smoke.test.mjs
954+++ b/tests/browser/browser-control-e2e-smoke.test.mjs
955@@ -179,7 +179,7 @@ async function expectQueueTimeout(queue, predicate, timeoutMs = 400) {
956 );
957 }
958
959-async function connectFirefoxBridgeClient(wsUrl, clientId) {
960+async function connectBrowserBridgeClient(wsUrl, clientId, nodePlatform = "firefox") {
961 const socket = new WebSocket(wsUrl);
962 const queue = createWebSocketMessageQueue(socket);
963
964@@ -190,7 +190,7 @@ async function connectFirefoxBridgeClient(wsUrl, clientId) {
965 clientId,
966 nodeType: "browser",
967 nodeCategory: "proxy",
968- nodePlatform: "firefox"
969+ nodePlatform
970 })
971 );
972
973@@ -213,6 +213,10 @@ async function connectFirefoxBridgeClient(wsUrl, clientId) {
974 };
975 }
976
977+async function connectFirefoxBridgeClient(wsUrl, clientId) {
978+ return await connectBrowserBridgeClient(wsUrl, clientId, "firefox");
979+}
980+
981 async function fetchJson(url, init) {
982 const response = await fetch(url, init);
983 const text = await response.text();
984@@ -2614,6 +2618,70 @@ test("browser control e2e smoke covers metadata read surface plus Claude and Cha
985 }
986 });
987
988+test("browser control smoke accepts Safari bridge clients on the canonical websocket path", async () => {
989+ const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-safari-ws-smoke-"));
990+ const runtime = new ConductorRuntime(
991+ {
992+ nodeId: "mini-main",
993+ host: "mini",
994+ role: "primary",
995+ controlApiBase: "https://conductor.example.test",
996+ localApiBase: "http://127.0.0.1:0",
997+ sharedToken: "replace-me",
998+ paths: {
999+ runsDir: "/tmp/runs",
1000+ stateDir
1001+ }
1002+ },
1003+ {
1004+ autoStartLoops: false,
1005+ now: () => 100
1006+ }
1007+ );
1008+
1009+ let client = null;
1010+
1011+ try {
1012+ const snapshot = await runtime.start();
1013+ const baseUrl = snapshot.controlApi.localApiBase;
1014+
1015+ client = await connectBrowserBridgeClient(
1016+ snapshot.controlApi.browserWsUrl,
1017+ "safari-browser-control-smoke",
1018+ "safari"
1019+ );
1020+
1021+ assert.equal(client.helloAck.clientId, "safari-browser-control-smoke");
1022+ assert.equal(client.helloAck.protocol, "baa.browser.local");
1023+ assert.equal(client.helloAck.wsUrl, snapshot.controlApi.browserWsUrl);
1024+ assert.deepEqual(client.helloAck.wsCompatUrls, [snapshot.controlApi.firefoxWsUrl]);
1025+ assert.equal(client.initialSnapshot.snapshot.browser.client_count, 1);
1026+ assert.equal(client.initialSnapshot.snapshot.browser.clients[0]?.node_platform, "safari");
1027+ assert.equal(client.initialSnapshot.snapshot.browser.ws_path, "/ws/browser");
1028+ assert.equal(client.credentialRequest.reason, "hello");
1029+
1030+ const browserStatus = await fetchJson(`${baseUrl}/v1/browser`);
1031+ assert.equal(browserStatus.response.status, 200);
1032+ assert.equal(browserStatus.payload.data.bridge.transport, "local_browser_ws");
1033+ assert.equal(browserStatus.payload.data.bridge.ws_path, "/ws/browser");
1034+ assert.equal(browserStatus.payload.data.bridge.ws_url, snapshot.controlApi.browserWsUrl);
1035+ assert.equal(browserStatus.payload.data.current_client.client_id, "safari-browser-control-smoke");
1036+ assert.equal(browserStatus.payload.data.current_client.node_platform, "safari");
1037+ } finally {
1038+ client?.queue.stop();
1039+
1040+ if (client?.socket && client.socket.readyState < WebSocket.CLOSING) {
1041+ client.socket.close(1000, "done");
1042+ }
1043+
1044+ await runtime.stop();
1045+ rmSync(stateDir, {
1046+ force: true,
1047+ recursive: true
1048+ });
1049+ }
1050+});
1051+
1052 test("browser delivery bridge uses proxy delivery on the routed business page and records target context", async () => {
1053 const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-smoke-"));
1054 const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-host-"));
1055@@ -2681,9 +2749,15 @@ test("browser delivery bridge uses proxy delivery on the routed business page an
1056 assert.equal(proxyDelivery.shell_page, false);
1057 assert.equal(proxyDelivery.target_tab_id, 51);
1058 assert.match(proxyDelivery.message_text, /\[BAA 执行结果\]/u);
1059- assert.match(proxyDelivery.message_text, /line-1/u);
1060+ assert.equal(
1061+ proxyDelivery.message_text.includes("\"command\": \"i=1; while [ $i -le 260"),
1062+ true
1063+ );
1064 assert.doesNotMatch(proxyDelivery.message_text, /line-260/u);
1065- assert.match(proxyDelivery.message_text, /超长截断$/u);
1066+ assert.match(
1067+ proxyDelivery.message_text,
1068+ /完整结果:https:\/\/conductor\.example\.test\/artifact\/exec\/[^ \n]+\.txt/u
1069+ );
1070
1071 await expectQueueTimeout(
1072 client.queue,