baa-conductor


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,