baa-conductor

git clone 

commit
62606cf
parent
667fc6a
author
im_wower
date
2026-03-24 01:27:55 +0800 CST
feat(conductor-daemon): add browser http surface
9 files changed,  +2008, -18
M README.md
+3, -2
 1@@ -42,7 +42,7 @@
 2    - `GET /describe/business`
 3    - 或 `GET /describe/control`
 4 4. 如有需要,再调 `GET /v1/capabilities`
 5-5. 完成能力感知后,再执行业务查询或控制动作;如果要调用 `/v1/exec` 或 `/v1/files/*`,必须带 `Authorization: Bearer <BAA_SHARED_TOKEN>`
 6+5. 完成能力感知后,再执行业务查询、Claude 浏览器动作或控制动作;如果要调用 `/v1/exec` 或 `/v1/files/*`,必须带 `Authorization: Bearer <BAA_SHARED_TOKEN>`
 7 
 8 ## 当前目录结构
 9 
10@@ -89,6 +89,7 @@ docs/
11 - 所有新公网说明统一写 `conductor.makefile.so`
12 - `status-api` 只作为本地只读观察面,不再作为默认对外业务接口
13 - 运行中的浏览器插件代码以 [`plugins/baa-firefox`](./plugins/baa-firefox) 为准
14+- 当前正式浏览器 HTTP 面是 `/v1/browser/*`,只支持 Claude,且通过本地 `/ws/firefox` 转发到 Firefox 插件页面内 HTTP 代理
15 - `codexd` 目前还是半成品,不是已上线组件
16 - `codexd` 必须作为独立常驻进程存在,不接受长期内嵌到 `conductor-daemon`
17 - `codexd` 后续默认以 `app-server` 为主,不以 TUI 或 `exec` 作为主双工接口
18@@ -97,7 +98,7 @@ docs/
19 
20 | 面 | 地址 | 定位 | 说明 |
21 | --- | --- | --- | --- |
22-| local API | `http://100.71.210.78:4317` | 唯一主接口、内网真相源 | 当前已承接 `/describe`、`/health`、`/version`、`/v1/capabilities`、`/v1/system/state`、`/v1/controllers`、`/v1/tasks`、`/v1/runs`、`pause/resume/drain`、`/v1/exec`、`/v1/files/read` 和 `/v1/files/write`;其中 host-ops 统一要求 `Authorization: Bearer <BAA_SHARED_TOKEN>` |
23+| local API | `http://100.71.210.78:4317` | 唯一主接口、内网真相源 | 当前已承接 `/describe`、`/health`、`/version`、`/v1/capabilities`、`/v1/browser/*`、`/v1/system/state`、`/v1/controllers`、`/v1/tasks`、`/v1/runs`、`pause/resume/drain`、`/v1/exec`、`/v1/files/read` 和 `/v1/files/write`;其中 `/v1/browser/*` 当前只支持 Claude,host-ops 统一要求 `Authorization: Bearer <BAA_SHARED_TOKEN>` |
24 | public host | `https://conductor.makefile.so` | 唯一公网域名 | 由 VPS Nginx 回源到 `100.71.210.78:4317`;`/v1/exec` 和 `/v1/files/*` 不再允许匿名调用 |
25 | local status view | `http://100.71.210.78:4318` | 本地只读观察面 | 迁移期保留,不是主控制面 |
26 
A apps/conductor-daemon/src/browser-types.ts
+77, -0
 1@@ -0,0 +1,77 @@
 2+export interface BrowserBridgeCredentialSnapshot {
 3+  captured_at: number;
 4+  header_count: number;
 5+  platform: string;
 6+}
 7+
 8+export interface BrowserBridgeRequestHookSnapshot {
 9+  endpoint_count: number;
10+  endpoints: string[];
11+  platform: string;
12+  updated_at: number;
13+}
14+
15+export interface BrowserBridgeClientSnapshot {
16+  client_id: string;
17+  connected_at: number;
18+  connection_id: string;
19+  credentials: BrowserBridgeCredentialSnapshot[];
20+  last_message_at: number;
21+  node_category: string | null;
22+  node_platform: string | null;
23+  node_type: string | null;
24+  request_hooks: BrowserBridgeRequestHookSnapshot[];
25+}
26+
27+export interface BrowserBridgeStateSnapshot {
28+  active_client_id: string | null;
29+  active_connection_id: string | null;
30+  client_count: number;
31+  clients: BrowserBridgeClientSnapshot[];
32+  ws_path: string;
33+  ws_url: string | null;
34+}
35+
36+export interface BrowserBridgeDispatchReceipt {
37+  clientId: string;
38+  connectionId: string;
39+  dispatchedAt: number;
40+  type: string;
41+}
42+
43+export interface BrowserBridgeApiResponse {
44+  body: unknown;
45+  clientId: string;
46+  connectionId: string;
47+  error: string | null;
48+  id: string;
49+  ok: boolean;
50+  respondedAt: number;
51+  status: number | null;
52+}
53+
54+export interface BrowserBridgeController {
55+  apiRequest(input: {
56+    body?: unknown;
57+    clientId?: string | null;
58+    headers?: Record<string, string> | null;
59+    id?: string | null;
60+    method?: string | null;
61+    path: string;
62+    platform: string;
63+    timeoutMs?: number | null;
64+  }): Promise<BrowserBridgeApiResponse>;
65+  openTab(input?: {
66+    clientId?: string | null;
67+    platform?: string | null;
68+  }): BrowserBridgeDispatchReceipt;
69+  reload(input?: {
70+    clientId?: string | null;
71+    reason?: string | null;
72+  }): BrowserBridgeDispatchReceipt;
73+  requestCredentials(input?: {
74+    clientId?: string | null;
75+    platform?: string | null;
76+    reason?: string | null;
77+  }): BrowserBridgeDispatchReceipt;
78+}
M apps/conductor-daemon/src/firefox-ws.ts
+28, -10
 1@@ -8,6 +8,10 @@ import {
 2   FirefoxCommandBroker,
 3   type FirefoxBridgeRegisteredClient
 4 } from "./firefox-bridge.js";
 5+import type {
 6+  BrowserBridgeClientSnapshot,
 7+  BrowserBridgeStateSnapshot
 8+} from "./browser-types.js";
 9 import { buildSystemStateData, setAutomationMode } from "./local-api.js";
10 import type { ConductorRuntimeSnapshot } from "./index.js";
11 
12@@ -281,7 +285,7 @@ class FirefoxWebSocketConnection {
13     this.session.lastMessageAt = Date.now();
14   }
15 
16-  describe(): Record<string, unknown> {
17+  describe(): BrowserBridgeClientSnapshot {
18     const credentials = [...this.session.credentials.entries()]
19       .sort(([left], [right]) => left.localeCompare(right))
20       .map(([platform, summary]) => ({
21@@ -512,6 +516,10 @@ export class ConductorFirefoxWebSocketServer {
22     return this.bridgeService;
23   }
24 
25+  getStateSnapshot(): BrowserBridgeStateSnapshot {
26+    return this.buildBrowserStateSnapshot();
27+  }
28+
29   start(): void {
30     if (this.pollTimer != null) {
31       return;
32@@ -854,17 +862,9 @@ export class ConductorFirefoxWebSocketServer {
33 
34   private async buildStateSnapshot(): Promise<Record<string, unknown>> {
35     const runtime = this.snapshotLoader();
36-    const clients = [...this.connections]
37-      .map((connection) => connection.describe())
38-      .sort((left, right) =>
39-        String(left.client_id ?? "").localeCompare(String(right.client_id ?? ""))
40-      );
41 
42     return {
43-      browser: {
44-        client_count: clients.length,
45-        clients
46-      },
47+      browser: this.buildBrowserStateSnapshot(),
48       server: {
49         host: runtime.daemon.host,
50         identity: runtime.identity,
51@@ -883,6 +883,24 @@ export class ConductorFirefoxWebSocketServer {
52     };
53   }
54 
55+  private buildBrowserStateSnapshot(): BrowserBridgeStateSnapshot {
56+    const clients = [...this.connections]
57+      .map((connection) => connection.describe())
58+      .sort((left, right) =>
59+        String(left.client_id ?? "").localeCompare(String(right.client_id ?? ""))
60+      );
61+    const activeClient = this.getActiveClient();
62+
63+    return {
64+      active_client_id: activeClient?.clientId ?? null,
65+      active_connection_id: activeClient?.connectionId ?? null,
66+      client_count: clients.length,
67+      clients,
68+      ws_path: FIREFOX_WS_PATH,
69+      ws_url: this.getUrl()
70+    };
71+  }
72+
73   private async sendStateSnapshotTo(
74     connection: FirefoxWebSocketConnection,
75     reason: string
M apps/conductor-daemon/src/index.test.js
+560, -0
  1@@ -466,6 +466,181 @@ function parseJsonBody(response) {
  2   return JSON.parse(response.body);
  3 }
  4 
  5+function createBrowserBridgeStub() {
  6+  const calls = [];
  7+  const browserState = {
  8+    active_client_id: "firefox-claude",
  9+    active_connection_id: "conn-firefox-claude",
 10+    client_count: 1,
 11+    clients: [
 12+      {
 13+        client_id: "firefox-claude",
 14+        connected_at: 1710000000000,
 15+        connection_id: "conn-firefox-claude",
 16+        credentials: [
 17+          {
 18+            platform: "claude",
 19+            captured_at: 1710000001000,
 20+            header_count: 3
 21+          }
 22+        ],
 23+        last_message_at: 1710000002000,
 24+        node_category: "proxy",
 25+        node_platform: "firefox",
 26+        node_type: "browser",
 27+        request_hooks: [
 28+          {
 29+            platform: "claude",
 30+            endpoint_count: 3,
 31+            endpoints: [
 32+              "GET /api/organizations",
 33+              "GET /api/organizations/{id}/chat_conversations/{id}",
 34+              "POST /api/organizations/{id}/chat_conversations/{id}/completion"
 35+            ],
 36+            updated_at: 1710000003000
 37+          }
 38+        ]
 39+      }
 40+    ],
 41+    ws_path: "/ws/firefox",
 42+    ws_url: "ws://127.0.0.1:4317/ws/firefox"
 43+  };
 44+
 45+  const buildApiResponse = ({ body, clientId = "firefox-claude", error = null, status = 200 }) => ({
 46+    body,
 47+    clientId,
 48+    connectionId: "conn-firefox-claude",
 49+    error,
 50+    id: `browser-${calls.length + 1}`,
 51+    ok: error == null && status < 400,
 52+    respondedAt: 1710000004000 + calls.length,
 53+    status
 54+  });
 55+
 56+  return {
 57+    calls,
 58+    context: {
 59+      browserBridge: {
 60+        async apiRequest(input) {
 61+          calls.push({
 62+            ...input,
 63+            kind: "apiRequest"
 64+          });
 65+
 66+          if (input.path === "/api/organizations") {
 67+            return buildApiResponse({
 68+              body: {
 69+                organizations: [
 70+                  {
 71+                    uuid: "org-1",
 72+                    name: "Claude Org",
 73+                    is_default: true
 74+                  }
 75+                ]
 76+              }
 77+            });
 78+          }
 79+
 80+          if (input.path === "/api/organizations/org-1/chat_conversations") {
 81+            return buildApiResponse({
 82+              body: {
 83+                chat_conversations: [
 84+                  {
 85+                    uuid: "conv-1",
 86+                    name: "Current Claude Chat",
 87+                    updated_at: "2026-03-24T12:00:00.000Z",
 88+                    selected: true
 89+                  }
 90+                ]
 91+              }
 92+            });
 93+          }
 94+
 95+          if (input.path === "/api/organizations/org-1/chat_conversations/conv-1") {
 96+            return buildApiResponse({
 97+              body: {
 98+                conversation: {
 99+                  uuid: "conv-1",
100+                  name: "Current Claude Chat",
101+                  updated_at: "2026-03-24T12:00:00.000Z"
102+                },
103+                messages: [
104+                  {
105+                    uuid: "msg-user-1",
106+                    sender: "human",
107+                    text: "hello claude"
108+                  },
109+                  {
110+                    uuid: "msg-assistant-1",
111+                    sender: "assistant",
112+                    content: [
113+                      {
114+                        text: "hello from claude"
115+                      }
116+                    ]
117+                  }
118+                ]
119+              }
120+            });
121+          }
122+
123+          if (input.path === "/api/organizations/org-1/chat_conversations/conv-1/completion") {
124+            return buildApiResponse({
125+              body: {
126+                accepted: true,
127+                conversation_uuid: "conv-1"
128+              },
129+              status: 202
130+            });
131+          }
132+
133+          throw new Error(`unexpected browser proxy path: ${input.path}`);
134+        },
135+        openTab(input = {}) {
136+          calls.push({
137+            ...input,
138+            kind: "openTab"
139+          });
140+
141+          return {
142+            clientId: input.clientId || "firefox-claude",
143+            connectionId: "conn-firefox-claude",
144+            dispatchedAt: 1710000005000,
145+            type: "open_tab"
146+          };
147+        },
148+        reload(input = {}) {
149+          calls.push({
150+            ...input,
151+            kind: "reload"
152+          });
153+
154+          return {
155+            clientId: input.clientId || "firefox-claude",
156+            connectionId: "conn-firefox-claude",
157+            dispatchedAt: 1710000006000,
158+            type: "reload"
159+          };
160+        },
161+        requestCredentials(input = {}) {
162+          calls.push({
163+            ...input,
164+            kind: "requestCredentials"
165+          });
166+
167+          return {
168+            clientId: input.clientId || "firefox-claude",
169+            connectionId: "conn-firefox-claude",
170+            dispatchedAt: 1710000007000,
171+            type: "request_credentials"
172+          };
173+        }
174+      },
175+      browserStateLoader: () => browserState
176+    }
177+  };
178+}
179+
180 async function withMockedPlatform(platform, callback) {
181   const descriptor = Object.getOwnPropertyDescriptor(process, "platform");
182 
183@@ -1015,6 +1190,7 @@ test("handleConductorHttpRequest keeps degraded runtimes observable but not read
184 
185 test("handleConductorHttpRequest serves the migrated local business endpoints from the local repository", async () => {
186   const { repository, sharedToken, snapshot } = await createLocalApiFixture();
187+  const browser = createBrowserBridgeStub();
188   const codexd = await startCodexdStubServer();
189   const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-conductor-local-host-http-"));
190   const authorizedHeaders = {
191@@ -1022,6 +1198,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
192   };
193   snapshot.codexd.localApiBase = codexd.baseUrl;
194   const localApiContext = {
195+    ...browser.context,
196     codexdLocalApiBase: codexd.baseUrl,
197     fetchImpl: globalThis.fetch,
198     repository,
199@@ -1050,6 +1227,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
200     assert.equal(describePayload.data.describe_endpoints.control.path, "/describe/control");
201     assert.equal(describePayload.data.codex.enabled, true);
202     assert.equal(describePayload.data.codex.target_base_url, codexd.baseUrl);
203+    assert.equal(describePayload.data.browser.route_prefix, "/v1/browser");
204     assert.equal(describePayload.data.host_operations.enabled, true);
205     assert.equal(describePayload.data.host_operations.auth.header, "Authorization: Bearer <BAA_SHARED_TOKEN>");
206     assert.equal(describePayload.data.host_operations.auth.configured, true);
207@@ -1068,6 +1246,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
208     assert.equal(businessDescribePayload.data.surface, "business");
209     assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/tasks/u);
210     assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/codex/u);
211+    assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/browser\/claude\/current/u);
212     assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/system\/pause/u);
213     assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/exec/u);
214     assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/runs/u);
215@@ -1109,9 +1288,37 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
216     assert.equal(capabilitiesResponse.status, 200);
217     const capabilitiesPayload = parseJsonBody(capabilitiesResponse);
218     assert.equal(capabilitiesPayload.data.codex.backend, "independent_codexd");
219+    assert.equal(capabilitiesPayload.data.browser.route_prefix, "/v1/browser");
220     assert.match(JSON.stringify(capabilitiesPayload.data.read_endpoints), /\/v1\/codex/u);
221+    assert.match(JSON.stringify(capabilitiesPayload.data.read_endpoints), /\/v1\/browser/u);
222     assert.doesNotMatch(JSON.stringify(capabilitiesPayload.data.read_endpoints), /\/v1\/runs/u);
223 
224+    const browserStatusResponse = await handleConductorHttpRequest(
225+      {
226+        method: "GET",
227+        path: "/v1/browser"
228+      },
229+      localApiContext
230+    );
231+    assert.equal(browserStatusResponse.status, 200);
232+    const browserStatusPayload = parseJsonBody(browserStatusResponse);
233+    assert.equal(browserStatusPayload.data.bridge.client_count, 1);
234+    assert.equal(browserStatusPayload.data.current_client.client_id, "firefox-claude");
235+    assert.equal(browserStatusPayload.data.claude.ready, true);
236+
237+    const browserOpenResponse = await handleConductorHttpRequest(
238+      {
239+        body: JSON.stringify({
240+          client_id: "firefox-claude"
241+        }),
242+        method: "POST",
243+        path: "/v1/browser/claude/open"
244+      },
245+      localApiContext
246+    );
247+    assert.equal(browserOpenResponse.status, 200);
248+    assert.equal(parseJsonBody(browserOpenResponse).data.client_id, "firefox-claude");
249+
250     const controllersResponse = await handleConductorHttpRequest(
251       {
252         method: "GET",
253@@ -1238,6 +1445,51 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
254     assert.equal(codexTurnPayload.data.accepted, true);
255     assert.equal(codexTurnPayload.data.turnId, "turn-created");
256 
257+    const browserSendResponse = await handleConductorHttpRequest(
258+      {
259+        body: JSON.stringify({
260+          prompt: "hello claude"
261+        }),
262+        method: "POST",
263+        path: "/v1/browser/claude/send"
264+      },
265+      localApiContext
266+    );
267+    assert.equal(browserSendResponse.status, 200);
268+    const browserSendPayload = parseJsonBody(browserSendResponse);
269+    assert.equal(browserSendPayload.data.organization.organization_id, "org-1");
270+    assert.equal(browserSendPayload.data.conversation.conversation_id, "conv-1");
271+    assert.equal(browserSendPayload.data.proxy.path, "/api/organizations/org-1/chat_conversations/conv-1/completion");
272+    assert.equal(browserSendPayload.data.response.accepted, true);
273+
274+    const browserCurrentResponse = await handleConductorHttpRequest(
275+      {
276+        method: "GET",
277+        path: "/v1/browser/claude/current"
278+      },
279+      localApiContext
280+    );
281+    assert.equal(browserCurrentResponse.status, 200);
282+    const browserCurrentPayload = parseJsonBody(browserCurrentResponse);
283+    assert.equal(browserCurrentPayload.data.organization.organization_id, "org-1");
284+    assert.equal(browserCurrentPayload.data.conversation.conversation_id, "conv-1");
285+    assert.equal(browserCurrentPayload.data.messages.length, 2);
286+    assert.equal(browserCurrentPayload.data.messages[0].role, "user");
287+    assert.equal(browserCurrentPayload.data.messages[1].role, "assistant");
288+
289+    const browserReloadResponse = await handleConductorHttpRequest(
290+      {
291+        body: JSON.stringify({
292+          reason: "integration_test"
293+        }),
294+        method: "POST",
295+        path: "/v1/browser/claude/reload"
296+      },
297+      localApiContext
298+    );
299+    assert.equal(browserReloadResponse.status, 200);
300+    assert.equal(parseJsonBody(browserReloadResponse).data.type, "reload");
301+
302     const runResponse = await handleConductorHttpRequest(
303       {
304         method: "GET",
305@@ -1417,6 +1669,19 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
306   );
307   assert.equal(codexd.requests[3].body.model, "gpt-5.4");
308   assert.equal(codexd.requests[4].body.sessionId, "session-demo");
309+  assert.deepEqual(
310+    browser.calls.map((entry) => entry.kind === "apiRequest" ? `${entry.kind}:${entry.method}:${entry.path}` : `${entry.kind}:${entry.platform || "-"}`),
311+    [
312+      "openTab:claude",
313+      "apiRequest:GET:/api/organizations",
314+      "apiRequest:GET:/api/organizations/org-1/chat_conversations",
315+      "apiRequest:POST:/api/organizations/org-1/chat_conversations/conv-1/completion",
316+      "apiRequest:GET:/api/organizations",
317+      "apiRequest:GET:/api/organizations/org-1/chat_conversations",
318+      "apiRequest:GET:/api/organizations/org-1/chat_conversations/conv-1",
319+      "reload:-"
320+    ]
321+  );
322 });
323 
324 test("handleConductorHttpRequest returns a codexd-specific availability error when the proxy target is down", async () => {
325@@ -1445,6 +1710,34 @@ test("handleConductorHttpRequest returns a codexd-specific availability error wh
326   assert.doesNotMatch(payload.message, /bridge/u);
327 });
328 
329+test("handleConductorHttpRequest returns a clear 503 for Claude browser actions without an active Firefox client", async () => {
330+  const { repository, snapshot } = await createLocalApiFixture();
331+
332+  const response = await handleConductorHttpRequest(
333+    {
334+      method: "GET",
335+      path: "/v1/browser/claude/current"
336+    },
337+    {
338+      browserStateLoader: () => ({
339+        active_client_id: null,
340+        active_connection_id: null,
341+        client_count: 0,
342+        clients: [],
343+        ws_path: "/ws/firefox",
344+        ws_url: snapshot.controlApi.firefoxWsUrl
345+      }),
346+      repository,
347+      snapshotLoader: () => snapshot
348+    }
349+  );
350+
351+  assert.equal(response.status, 503);
352+  const payload = parseJsonBody(response);
353+  assert.equal(payload.ok, false);
354+  assert.equal(payload.error, "browser_bridge_unavailable");
355+});
356+
357 test("ConductorRuntime serves health and migrated local API endpoints over HTTP", async () => {
358   const codexd = await startCodexdStubServer();
359   const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-runtime-"));
360@@ -1963,6 +2256,273 @@ test("ConductorRuntime exposes Firefox outbound bridge commands and api request
361   }
362 });
363 
364+test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Firefox bridge", async () => {
365+  const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-browser-http-"));
366+  const runtime = new ConductorRuntime(
367+    {
368+      nodeId: "mini-main",
369+      host: "mini",
370+      role: "primary",
371+      controlApiBase: "https://control.example.test",
372+      localApiBase: "http://127.0.0.1:0",
373+      sharedToken: "replace-me",
374+      paths: {
375+        runsDir: "/tmp/runs",
376+        stateDir
377+      }
378+    },
379+    {
380+      autoStartLoops: false,
381+      now: () => 100
382+    }
383+  );
384+
385+  let client = null;
386+
387+  try {
388+    const snapshot = await runtime.start();
389+    client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-claude-http");
390+    const baseUrl = snapshot.controlApi.localApiBase;
391+
392+    client.socket.send(
393+      JSON.stringify({
394+        type: "credentials",
395+        platform: "claude",
396+        headers: {
397+          cookie: "session=1",
398+          "x-csrf-token": "token-1"
399+        },
400+        timestamp: 1710000001000
401+      })
402+    );
403+    await client.queue.next(
404+      (message) => message.type === "state_snapshot" && message.reason === "credentials"
405+    );
406+
407+    client.socket.send(
408+      JSON.stringify({
409+        type: "api_endpoints",
410+        platform: "claude",
411+        endpoints: [
412+          "GET /api/organizations",
413+          "GET /api/organizations/{id}/chat_conversations/{id}",
414+          "POST /api/organizations/{id}/chat_conversations/{id}/completion"
415+        ]
416+      })
417+    );
418+    await client.queue.next(
419+      (message) => message.type === "state_snapshot" && message.reason === "api_endpoints"
420+    );
421+
422+    const browserStatusResponse = await fetch(`${baseUrl}/v1/browser`);
423+    assert.equal(browserStatusResponse.status, 200);
424+    const browserStatusPayload = await browserStatusResponse.json();
425+    assert.equal(browserStatusPayload.data.bridge.client_count, 1);
426+    assert.equal(browserStatusPayload.data.claude.ready, true);
427+    assert.equal(browserStatusPayload.data.current_client.client_id, "firefox-claude-http");
428+
429+    const openResponse = await fetch(`${baseUrl}/v1/browser/claude/open`, {
430+      method: "POST",
431+      headers: {
432+        "content-type": "application/json"
433+      },
434+      body: JSON.stringify({
435+        client_id: "firefox-claude-http"
436+      })
437+    });
438+    assert.equal(openResponse.status, 200);
439+    const openMessage = await client.queue.next((message) => message.type === "open_tab");
440+    assert.equal(openMessage.platform, "claude");
441+
442+    const sendPromise = fetch(`${baseUrl}/v1/browser/claude/send`, {
443+      method: "POST",
444+      headers: {
445+        "content-type": "application/json"
446+      },
447+      body: JSON.stringify({
448+        prompt: "hello from conductor http"
449+      })
450+    });
451+
452+    const orgRequest = await client.queue.next(
453+      (message) => message.type === "api_request" && message.path === "/api/organizations"
454+    );
455+    client.socket.send(
456+      JSON.stringify({
457+        type: "api_response",
458+        id: orgRequest.id,
459+        ok: true,
460+        status: 200,
461+        body: {
462+          organizations: [
463+            {
464+              uuid: "org-http-1",
465+              name: "HTTP Org",
466+              is_default: true
467+            }
468+          ]
469+        }
470+      })
471+    );
472+
473+    const conversationsRequest = await client.queue.next(
474+      (message) => message.type === "api_request" && message.path === "/api/organizations/org-http-1/chat_conversations"
475+    );
476+    client.socket.send(
477+      JSON.stringify({
478+        type: "api_response",
479+        id: conversationsRequest.id,
480+        ok: true,
481+        status: 200,
482+        body: {
483+          chat_conversations: [
484+            {
485+              uuid: "conv-http-1",
486+              name: "HTTP Chat",
487+              selected: true
488+            }
489+          ]
490+        }
491+      })
492+    );
493+
494+    const completionRequest = await client.queue.next(
495+      (message) =>
496+        message.type === "api_request"
497+        && message.path === "/api/organizations/org-http-1/chat_conversations/conv-http-1/completion"
498+    );
499+    assert.equal(completionRequest.body.prompt, "hello from conductor http");
500+    client.socket.send(
501+      JSON.stringify({
502+        type: "api_response",
503+        id: completionRequest.id,
504+        ok: true,
505+        status: 202,
506+        body: {
507+          accepted: true,
508+          conversation_uuid: "conv-http-1"
509+        }
510+      })
511+    );
512+
513+    const sendResponse = await sendPromise;
514+    assert.equal(sendResponse.status, 200);
515+    const sendPayload = await sendResponse.json();
516+    assert.equal(sendPayload.data.organization.organization_id, "org-http-1");
517+    assert.equal(sendPayload.data.conversation.conversation_id, "conv-http-1");
518+    assert.equal(sendPayload.data.response.accepted, true);
519+
520+    const currentPromise = fetch(`${baseUrl}/v1/browser/claude/current`);
521+
522+    const currentOrgRequest = await client.queue.next(
523+      (message) => message.type === "api_request" && message.path === "/api/organizations"
524+    );
525+    client.socket.send(
526+      JSON.stringify({
527+        type: "api_response",
528+        id: currentOrgRequest.id,
529+        ok: true,
530+        status: 200,
531+        body: {
532+          organizations: [
533+            {
534+              uuid: "org-http-1",
535+              name: "HTTP Org",
536+              is_default: true
537+            }
538+          ]
539+        }
540+      })
541+    );
542+
543+    const currentConversationListRequest = await client.queue.next(
544+      (message) => message.type === "api_request" && message.path === "/api/organizations/org-http-1/chat_conversations"
545+    );
546+    client.socket.send(
547+      JSON.stringify({
548+        type: "api_response",
549+        id: currentConversationListRequest.id,
550+        ok: true,
551+        status: 200,
552+        body: {
553+          chat_conversations: [
554+            {
555+              uuid: "conv-http-1",
556+              name: "HTTP Chat",
557+              selected: true
558+            }
559+          ]
560+        }
561+      })
562+    );
563+
564+    const currentDetailRequest = await client.queue.next(
565+      (message) =>
566+        message.type === "api_request"
567+        && message.path === "/api/organizations/org-http-1/chat_conversations/conv-http-1"
568+    );
569+    client.socket.send(
570+      JSON.stringify({
571+        type: "api_response",
572+        id: currentDetailRequest.id,
573+        ok: true,
574+        status: 200,
575+        body: {
576+          conversation: {
577+            uuid: "conv-http-1",
578+            name: "HTTP Chat"
579+          },
580+          messages: [
581+            {
582+              uuid: "msg-http-user",
583+              sender: "human",
584+              text: "hello from conductor http"
585+            },
586+            {
587+              uuid: "msg-http-assistant",
588+              sender: "assistant",
589+              content: [
590+                {
591+                  text: "hello from claude http"
592+                }
593+              ]
594+            }
595+          ]
596+        }
597+      })
598+    );
599+
600+    const currentResponse = await currentPromise;
601+    assert.equal(currentResponse.status, 200);
602+    const currentPayload = await currentResponse.json();
603+    assert.equal(currentPayload.data.organization.organization_id, "org-http-1");
604+    assert.equal(currentPayload.data.messages.length, 2);
605+    assert.equal(currentPayload.data.messages[0].role, "user");
606+    assert.equal(currentPayload.data.messages[1].role, "assistant");
607+
608+    const reloadResponse = await fetch(`${baseUrl}/v1/browser/claude/reload`, {
609+      method: "POST",
610+      headers: {
611+        "content-type": "application/json"
612+      },
613+      body: JSON.stringify({
614+        reason: "http_integration_test"
615+      })
616+    });
617+    assert.equal(reloadResponse.status, 200);
618+    const reloadMessage = await client.queue.next((message) => message.type === "reload");
619+    assert.equal(reloadMessage.reason, "http_integration_test");
620+  } finally {
621+    client?.queue.stop();
622+    client?.socket.close(1000, "done");
623+    await runtime.stop();
624+    rmSync(stateDir, {
625+      force: true,
626+      recursive: true
627+    });
628+  }
629+});
630+
631 test("Firefox bridge api requests reject on timeout, disconnect, and replacement", async () => {
632   const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-firefox-bridge-errors-"));
633   const runtime = new ConductorRuntime(
M apps/conductor-daemon/src/index.ts
+2, -0
1@@ -638,6 +638,8 @@ class ConductorLocalHttpServer {
2             path: request.url ?? "/"
3           },
4           {
5+            browserBridge: this.firefoxWebSocketServer.getBridgeService(),
6+            browserStateLoader: () => this.firefoxWebSocketServer.getStateSnapshot(),
7             codexdLocalApiBase: this.codexdLocalApiBase,
8             fetchImpl: this.fetchImpl,
9             repository: this.repository,
M apps/conductor-daemon/src/local-api.ts
+1270, -2
   1@@ -30,11 +30,20 @@ import {
   2   type ConductorHttpRequest,
   3   type ConductorHttpResponse
   4 } from "./http-types.js";
   5+import type {
   6+  BrowserBridgeApiResponse,
   7+  BrowserBridgeClientSnapshot,
   8+  BrowserBridgeController,
   9+  BrowserBridgeCredentialSnapshot,
  10+  BrowserBridgeRequestHookSnapshot,
  11+  BrowserBridgeStateSnapshot
  12+} from "./browser-types.js";
  13 
  14 const DEFAULT_LIST_LIMIT = 20;
  15 const DEFAULT_LOG_LIMIT = 200;
  16 const MAX_LIST_LIMIT = 100;
  17 const MAX_LOG_LIMIT = 500;
  18+const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
  19 const TASK_STATUS_SET = new Set<TaskStatus>(TASK_STATUS_VALUES);
  20 const CODEXD_LOCAL_API_ENV = "BAA_CODEXD_LOCAL_API_BASE";
  21 const CODEX_ROUTE_IDS = new Set([
  22@@ -47,6 +56,12 @@ const CODEX_ROUTE_IDS = new Set([
  23 const HOST_OPERATIONS_ROUTE_IDS = new Set(["host.exec", "host.files.read", "host.files.write"]);
  24 const HOST_OPERATIONS_AUTH_HEADER = "Authorization: Bearer <BAA_SHARED_TOKEN>";
  25 const HOST_OPERATIONS_WWW_AUTHENTICATE = 'Bearer realm="baa-conductor-host-ops"';
  26+const BROWSER_CLAUDE_PLATFORM = "claude";
  27+const BROWSER_CLAUDE_ROOT_URL = "https://claude.ai/";
  28+const BROWSER_CLAUDE_ORGANIZATIONS_PATH = "/api/organizations";
  29+const BROWSER_CLAUDE_CONVERSATIONS_PATH = "/api/organizations/{id}/chat_conversations";
  30+const BROWSER_CLAUDE_CONVERSATION_PATH = "/api/organizations/{id}/chat_conversations/{id}";
  31+const BROWSER_CLAUDE_COMPLETION_PATH = "/api/organizations/{id}/chat_conversations/{id}/completion";
  32 
  33 type LocalApiRouteMethod = "GET" | "POST";
  34 type LocalApiRouteKind = "probe" | "read" | "write";
  35@@ -84,6 +99,8 @@ type UpstreamErrorEnvelope = JsonObject & {
  36 };
  37 
  38 interface LocalApiRequestContext {
  39+  browserBridge: BrowserBridgeController | null;
  40+  browserStateLoader: () => BrowserBridgeStateSnapshot | null;
  41   codexdLocalApiBase: string | null;
  42   fetchImpl: typeof fetch;
  43   now: () => number;
  44@@ -128,6 +145,8 @@ export interface ConductorRuntimeApiSnapshot {
  45 }
  46 
  47 export interface ConductorLocalApiContext {
  48+  browserBridge?: BrowserBridgeController | null;
  49+  browserStateLoader?: (() => BrowserBridgeStateSnapshot | null) | null;
  50   codexdLocalApiBase?: string | null;
  51   fetchImpl?: typeof fetch;
  52   now?: () => number;
  53@@ -259,6 +278,41 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
  54     pathPattern: "/v1/codex/turn",
  55     summary: "向独立 codexd 会话提交 turn"
  56   },
  57+  {
  58+    id: "browser.status",
  59+    kind: "read",
  60+    method: "GET",
  61+    pathPattern: "/v1/browser",
  62+    summary: "读取本地浏览器 bridge 摘要与 Claude 就绪状态"
  63+  },
  64+  {
  65+    id: "browser.claude.open",
  66+    kind: "write",
  67+    method: "POST",
  68+    pathPattern: "/v1/browser/claude/open",
  69+    summary: "打开或聚焦 Claude 标签页"
  70+  },
  71+  {
  72+    id: "browser.claude.send",
  73+    kind: "write",
  74+    method: "POST",
  75+    pathPattern: "/v1/browser/claude/send",
  76+    summary: "通过本地 Firefox bridge 发起一轮 Claude 对话"
  77+  },
  78+  {
  79+    id: "browser.claude.current",
  80+    kind: "read",
  81+    method: "GET",
  82+    pathPattern: "/v1/browser/claude/current",
  83+    summary: "读取当前 Claude 对话内容与页面代理状态"
  84+  },
  85+  {
  86+    id: "browser.claude.reload",
  87+    kind: "write",
  88+    method: "POST",
  89+    pathPattern: "/v1/browser/claude/reload",
  90+    summary: "请求当前 Claude 浏览器 bridge 页面重载"
  91+  },
  92   {
  93     id: "system.state",
  94     kind: "read",
  95@@ -855,6 +909,975 @@ function readNumberValue(record: JsonObject | null, fieldName: string): number |
  96   return typeof value === "number" && Number.isFinite(value) ? value : null;
  97 }
  98 
  99+interface ParsedBrowserProxyResponse {
 100+  apiResponse: BrowserBridgeApiResponse;
 101+  body: JsonValue | string | null;
 102+  rootObject: JsonObject | null;
 103+}
 104+
 105+interface ClaudeBrowserSelection {
 106+  client: BrowserBridgeClientSnapshot | null;
 107+  credential: BrowserBridgeCredentialSnapshot | null;
 108+  requestHook: BrowserBridgeRequestHookSnapshot | null;
 109+}
 110+
 111+interface ReadyClaudeBrowserSelection {
 112+  client: BrowserBridgeClientSnapshot;
 113+  credential: BrowserBridgeCredentialSnapshot;
 114+  requestHook: BrowserBridgeRequestHookSnapshot | null;
 115+}
 116+
 117+interface ClaudeOrganizationSummary {
 118+  id: string;
 119+  name: string | null;
 120+  raw: JsonObject | null;
 121+}
 122+
 123+interface ClaudeConversationSummary {
 124+  created_at: number | null;
 125+  id: string;
 126+  raw: JsonObject | null;
 127+  title: string | null;
 128+  updated_at: number | null;
 129+}
 130+
 131+function asUnknownRecord(value: unknown): Record<string, unknown> | null {
 132+  if (value === null || typeof value !== "object" || Array.isArray(value)) {
 133+    return null;
 134+  }
 135+
 136+  return value as Record<string, unknown>;
 137+}
 138+
 139+function readUnknownString(
 140+  input: Record<string, unknown> | null,
 141+  fieldNames: readonly string[]
 142+): string | null {
 143+  if (input == null) {
 144+    return null;
 145+  }
 146+
 147+  for (const fieldName of fieldNames) {
 148+    const value = input[fieldName];
 149+
 150+    if (typeof value === "string") {
 151+      const normalized = value.trim();
 152+
 153+      if (normalized !== "") {
 154+        return normalized;
 155+      }
 156+    }
 157+  }
 158+
 159+  return null;
 160+}
 161+
 162+function readUnknownBoolean(
 163+  input: Record<string, unknown> | null,
 164+  fieldNames: readonly string[]
 165+): boolean | null {
 166+  if (input == null) {
 167+    return null;
 168+  }
 169+
 170+  for (const fieldName of fieldNames) {
 171+    const value = input[fieldName];
 172+
 173+    if (typeof value === "boolean") {
 174+      return value;
 175+    }
 176+  }
 177+
 178+  return null;
 179+}
 180+
 181+function normalizeTimestampLike(value: unknown): number | null {
 182+  if (typeof value === "number" && Number.isFinite(value) && value > 0) {
 183+    return Math.round(value >= 1_000_000_000_000 ? value : value * 1000);
 184+  }
 185+
 186+  if (typeof value !== "string") {
 187+    return null;
 188+  }
 189+
 190+  const normalized = value.trim();
 191+
 192+  if (normalized === "") {
 193+    return null;
 194+  }
 195+
 196+  const numeric = Number(normalized);
 197+
 198+  if (Number.isFinite(numeric) && numeric > 0) {
 199+    return Math.round(numeric >= 1_000_000_000_000 ? numeric : numeric * 1000);
 200+  }
 201+
 202+  const timestamp = Date.parse(normalized);
 203+  return Number.isFinite(timestamp) ? timestamp : null;
 204+}
 205+
 206+function parseBrowserProxyBody(body: unknown): JsonValue | string | null {
 207+  if (body == null) {
 208+    return null;
 209+  }
 210+
 211+  if (typeof body === "string") {
 212+    const normalized = body.trim();
 213+
 214+    if (normalized === "") {
 215+      return null;
 216+    }
 217+
 218+    try {
 219+      return JSON.parse(normalized) as JsonValue;
 220+    } catch {
 221+      return body;
 222+    }
 223+  }
 224+
 225+  if (
 226+    typeof body === "boolean"
 227+    || typeof body === "number"
 228+    || Array.isArray(body)
 229+    || typeof body === "object"
 230+  ) {
 231+    return body as JsonValue;
 232+  }
 233+
 234+  return String(body);
 235+}
 236+
 237+function readOptionalQueryString(url: URL, ...fieldNames: string[]): string | undefined {
 238+  for (const fieldName of fieldNames) {
 239+    const value = url.searchParams.get(fieldName);
 240+
 241+    if (typeof value === "string") {
 242+      const normalized = value.trim();
 243+
 244+      if (normalized !== "") {
 245+        return normalized;
 246+      }
 247+    }
 248+  }
 249+
 250+  return undefined;
 251+}
 252+
 253+function readOptionalNumberField(body: JsonObject, fieldName: string): number | undefined {
 254+  const value = body[fieldName];
 255+
 256+  if (value == null) {
 257+    return undefined;
 258+  }
 259+
 260+  if (typeof value !== "number" || !Number.isFinite(value)) {
 261+    throw new LocalApiHttpError(400, "invalid_request", `Field "${fieldName}" must be a finite number.`, {
 262+      field: fieldName
 263+    });
 264+  }
 265+
 266+  return value;
 267+}
 268+
 269+function readOptionalObjectField(body: JsonObject, fieldName: string): JsonObject | undefined {
 270+  const value = body[fieldName];
 271+
 272+  if (value == null) {
 273+    return undefined;
 274+  }
 275+
 276+  if (!isJsonObject(value)) {
 277+    throw new LocalApiHttpError(400, "invalid_request", `Field "${fieldName}" must be a JSON object.`, {
 278+      field: fieldName
 279+    });
 280+  }
 281+
 282+  return value;
 283+}
 284+
 285+function readOptionalStringMap(body: JsonObject, fieldName: string): Record<string, string> | undefined {
 286+  const value = readOptionalObjectField(body, fieldName);
 287+
 288+  if (value == null) {
 289+    return undefined;
 290+  }
 291+
 292+  const normalized: Record<string, string> = {};
 293+
 294+  for (const [name, entry] of Object.entries(value)) {
 295+    if (typeof entry !== "string") {
 296+      throw new LocalApiHttpError(
 297+        400,
 298+        "invalid_request",
 299+        `Field "${fieldName}.${name}" must be a string.`,
 300+        {
 301+          field: fieldName,
 302+          header: name
 303+        }
 304+      );
 305+    }
 306+
 307+    const normalizedName = name.trim();
 308+
 309+    if (normalizedName === "") {
 310+      continue;
 311+    }
 312+
 313+    normalized[normalizedName] = entry;
 314+  }
 315+
 316+  return Object.keys(normalized).length > 0 ? normalized : undefined;
 317+}
 318+
 319+function readOptionalTimeoutMs(body: JsonObject, url: URL): number | undefined {
 320+  const bodyTimeoutMs =
 321+    readOptionalNumberField(body, "timeoutMs")
 322+    ?? readOptionalNumberField(body, "timeout_ms");
 323+
 324+  if (bodyTimeoutMs !== undefined) {
 325+    if (bodyTimeoutMs <= 0) {
 326+      throw new LocalApiHttpError(400, "invalid_request", 'Field "timeoutMs" must be greater than 0.', {
 327+        field: "timeoutMs"
 328+      });
 329+    }
 330+
 331+    return Math.round(bodyTimeoutMs);
 332+  }
 333+
 334+  const queryTimeoutMs = readOptionalQueryString(url, "timeoutMs", "timeout_ms");
 335+
 336+  if (queryTimeoutMs == null) {
 337+    return undefined;
 338+  }
 339+
 340+  const numeric = Number(queryTimeoutMs);
 341+
 342+  if (!Number.isFinite(numeric) || numeric <= 0) {
 343+    throw new LocalApiHttpError(400, "invalid_request", 'Query parameter "timeoutMs" must be greater than 0.', {
 344+      field: "timeoutMs"
 345+    });
 346+  }
 347+
 348+  return Math.round(numeric);
 349+}
 350+
 351+function createEmptyBrowserState(snapshot: ConductorRuntimeApiSnapshot): BrowserBridgeStateSnapshot {
 352+  return {
 353+    active_client_id: null,
 354+    active_connection_id: null,
 355+    client_count: 0,
 356+    clients: [],
 357+    ws_path: "/ws/firefox",
 358+    ws_url: snapshot.controlApi.firefoxWsUrl ?? null
 359+  };
 360+}
 361+
 362+function loadBrowserState(context: LocalApiRequestContext): BrowserBridgeStateSnapshot {
 363+  return context.browserStateLoader() ?? createEmptyBrowserState(context.snapshotLoader());
 364+}
 365+
 366+function selectClaudeBrowserClient(
 367+  state: BrowserBridgeStateSnapshot,
 368+  requestedClientId?: string | null
 369+): ClaudeBrowserSelection {
 370+  const normalizedRequestedClientId = normalizeOptionalString(requestedClientId);
 371+  const client =
 372+    normalizedRequestedClientId == null
 373+      ? (
 374+          state.clients.find((entry) => entry.client_id === state.active_client_id)
 375+          ?? [...state.clients].sort((left, right) => right.last_message_at - left.last_message_at)[0]
 376+          ?? null
 377+        )
 378+      : state.clients.find((entry) => entry.client_id === normalizedRequestedClientId) ?? null;
 379+
 380+  return {
 381+    client,
 382+    credential: client?.credentials.find((entry) => entry.platform === BROWSER_CLAUDE_PLATFORM) ?? null,
 383+    requestHook: client?.request_hooks.find((entry) => entry.platform === BROWSER_CLAUDE_PLATFORM) ?? null
 384+  };
 385+}
 386+
 387+function readBridgeErrorCode(error: unknown): string | null {
 388+  return readUnknownString(asUnknownRecord(error), ["code"]);
 389+}
 390+
 391+function createBrowserBridgeHttpError(action: string, error: unknown): LocalApiHttpError {
 392+  const record = asUnknownRecord(error);
 393+  const code = readBridgeErrorCode(error);
 394+  const details = compactJsonObject({
 395+    action,
 396+    bridge_client_id: readUnknownString(record, ["clientId", "client_id"]),
 397+    bridge_connection_id: readUnknownString(record, ["connectionId", "connection_id"]),
 398+    bridge_request_id: readUnknownString(record, ["requestId", "request_id", "id"]),
 399+    cause: error instanceof Error ? error.message : String(error),
 400+    error_code: code
 401+  });
 402+
 403+  switch (code) {
 404+    case "no_active_client":
 405+      return new LocalApiHttpError(
 406+        503,
 407+        "browser_bridge_unavailable",
 408+        "No active Firefox bridge client is connected.",
 409+        details
 410+      );
 411+    case "client_not_found":
 412+      return new LocalApiHttpError(
 413+        409,
 414+        "browser_client_not_found",
 415+        "The requested Firefox bridge client is not connected.",
 416+        details
 417+      );
 418+    case "duplicate_request_id":
 419+      return new LocalApiHttpError(
 420+        409,
 421+        "browser_request_conflict",
 422+        "The requested browser proxy request id is already in flight.",
 423+        details
 424+      );
 425+    case "request_timeout":
 426+      return new LocalApiHttpError(
 427+        504,
 428+        "browser_request_timeout",
 429+        `Timed out while waiting for the browser bridge to complete ${action}.`,
 430+        details
 431+      );
 432+    case "client_disconnected":
 433+    case "client_replaced":
 434+    case "send_failed":
 435+    case "service_stopped":
 436+      return new LocalApiHttpError(
 437+        503,
 438+        "browser_bridge_unavailable",
 439+        `The Firefox bridge became unavailable while processing ${action}.`,
 440+        details
 441+      );
 442+    default:
 443+      return new LocalApiHttpError(
 444+        502,
 445+        "browser_bridge_error",
 446+        `Failed to execute ${action} through the Firefox bridge.`,
 447+        details
 448+      );
 449+  }
 450+}
 451+
 452+function requireBrowserBridge(context: LocalApiRequestContext): BrowserBridgeController {
 453+  if (context.browserBridge == null) {
 454+    throw new LocalApiHttpError(
 455+      503,
 456+      "browser_bridge_unavailable",
 457+      "Firefox browser bridge is not configured on this conductor runtime."
 458+    );
 459+  }
 460+
 461+  return context.browserBridge;
 462+}
 463+
 464+function ensureClaudeBridgeReady(
 465+  selection: ClaudeBrowserSelection,
 466+  requestedClientId?: string | null
 467+): ReadyClaudeBrowserSelection {
 468+  if (selection.client == null) {
 469+    throw new LocalApiHttpError(
 470+      503,
 471+      "browser_bridge_unavailable",
 472+      "No active Firefox bridge client is connected for Claude actions."
 473+    );
 474+  }
 475+
 476+  if (selection.credential == null) {
 477+    throw new LocalApiHttpError(
 478+      409,
 479+      "claude_credentials_unavailable",
 480+      "Claude credentials are not available yet on the selected Firefox bridge client.",
 481+      compactJsonObject({
 482+        client_id: selection.client.client_id,
 483+        requested_client_id: normalizeOptionalString(requestedClientId),
 484+        suggested_action: "Open Claude in Firefox and let the extension observe one real request first."
 485+      })
 486+    );
 487+  }
 488+
 489+  return {
 490+    client: selection.client,
 491+    credential: selection.credential,
 492+    requestHook: selection.requestHook
 493+  };
 494+}
 495+
 496+function serializeBrowserCredentialSnapshot(snapshot: BrowserBridgeCredentialSnapshot): JsonObject {
 497+  return {
 498+    captured_at: snapshot.captured_at,
 499+    header_count: snapshot.header_count,
 500+    platform: snapshot.platform
 501+  };
 502+}
 503+
 504+function serializeBrowserRequestHookSnapshot(snapshot: BrowserBridgeRequestHookSnapshot): JsonObject {
 505+  return {
 506+    endpoint_count: snapshot.endpoint_count,
 507+    endpoints: [...snapshot.endpoints],
 508+    platform: snapshot.platform,
 509+    updated_at: snapshot.updated_at
 510+  };
 511+}
 512+
 513+function serializeBrowserClientSnapshot(snapshot: BrowserBridgeClientSnapshot): JsonObject {
 514+  return {
 515+    client_id: snapshot.client_id,
 516+    connected_at: snapshot.connected_at,
 517+    connection_id: snapshot.connection_id,
 518+    credentials: snapshot.credentials.map(serializeBrowserCredentialSnapshot),
 519+    last_message_at: snapshot.last_message_at,
 520+    node_category: snapshot.node_category,
 521+    node_platform: snapshot.node_platform,
 522+    node_type: snapshot.node_type,
 523+    request_hooks: snapshot.request_hooks.map(serializeBrowserRequestHookSnapshot)
 524+  };
 525+}
 526+
 527+function extractArrayObjects(value: JsonValue | string | null, fieldNames: readonly string[]): JsonObject[] {
 528+  if (Array.isArray(value)) {
 529+    return value.filter((entry): entry is JsonObject => isJsonObject(entry));
 530+  }
 531+
 532+  if (!isJsonObject(value)) {
 533+    return [];
 534+  }
 535+
 536+  for (const fieldName of fieldNames) {
 537+    const directValue = value[fieldName];
 538+
 539+    if (Array.isArray(directValue)) {
 540+      return directValue.filter((entry): entry is JsonObject => isJsonObject(entry));
 541+    }
 542+  }
 543+
 544+  const dataObject = readJsonObjectField(value, "data");
 545+
 546+  if (dataObject != null) {
 547+    for (const fieldName of fieldNames) {
 548+      const directValue = dataObject[fieldName];
 549+
 550+      if (Array.isArray(directValue)) {
 551+        return directValue.filter((entry): entry is JsonObject => isJsonObject(entry));
 552+      }
 553+    }
 554+  }
 555+
 556+  const dataValue = value.data;
 557+
 558+  if (Array.isArray(dataValue)) {
 559+    return dataValue.filter((entry): entry is JsonObject => isJsonObject(entry));
 560+  }
 561+
 562+  return [];
 563+}
 564+
 565+function summarizeClaudeOrganization(record: JsonObject): ClaudeOrganizationSummary | null {
 566+  const organizationId =
 567+    readStringValue(record, "uuid")
 568+    ?? readStringValue(record, "id")
 569+    ?? readStringValue(record, "organization_uuid")
 570+    ?? readStringValue(record, "organizationId");
 571+
 572+  if (organizationId == null) {
 573+    return null;
 574+  }
 575+
 576+  return {
 577+    id: organizationId,
 578+    name:
 579+      readStringValue(record, "name")
 580+      ?? readStringValue(record, "display_name")
 581+      ?? readStringValue(record, "slug"),
 582+    raw: record
 583+  };
 584+}
 585+
 586+function summarizeClaudeConversation(record: JsonObject): ClaudeConversationSummary | null {
 587+  const conversationId =
 588+    readStringValue(record, "uuid")
 589+    ?? readStringValue(record, "id")
 590+    ?? readStringValue(record, "conversation_uuid")
 591+    ?? readStringValue(record, "chat_conversation_uuid");
 592+
 593+  if (conversationId == null) {
 594+    return null;
 595+  }
 596+
 597+  return {
 598+    created_at: normalizeTimestampLike(
 599+      record.created_at
 600+      ?? record.createdAt
 601+      ?? record.inserted_at
 602+      ?? record.insertedAt
 603+    ),
 604+    id: conversationId,
 605+    raw: record,
 606+    title:
 607+      readStringValue(record, "name")
 608+      ?? readStringValue(record, "title")
 609+      ?? readStringValue(record, "summary"),
 610+    updated_at: normalizeTimestampLike(
 611+      record.updated_at
 612+      ?? record.updatedAt
 613+      ?? record.last_updated_at
 614+      ?? record.lastUpdatedAt
 615+    )
 616+  };
 617+}
 618+
 619+function pickLatestClaudeConversation(
 620+  conversations: ClaudeConversationSummary[]
 621+): ClaudeConversationSummary | null {
 622+  if (conversations.length === 0) {
 623+    return null;
 624+  }
 625+
 626+  const currentConversation = conversations.find((conversation) =>
 627+    readUnknownBoolean(asUnknownRecord(conversation.raw), ["is_current", "current", "selected", "active"]) === true
 628+  );
 629+
 630+  if (currentConversation != null) {
 631+    return currentConversation;
 632+  }
 633+
 634+  return [...conversations].sort((left, right) => {
 635+    const rightTimestamp = right.updated_at ?? right.created_at ?? 0;
 636+    const leftTimestamp = left.updated_at ?? left.created_at ?? 0;
 637+    return rightTimestamp - leftTimestamp;
 638+  })[0] ?? null;
 639+}
 640+
 641+function extractTextFragments(value: JsonValue | null): string[] {
 642+  if (typeof value === "string") {
 643+    const normalized = value.trim();
 644+    return normalized === "" ? [] : [normalized];
 645+  }
 646+
 647+  if (Array.isArray(value)) {
 648+    return value.flatMap((entry) => extractTextFragments(entry));
 649+  }
 650+
 651+  if (!isJsonObject(value)) {
 652+    return [];
 653+  }
 654+
 655+  const directKeys = ["text", "value", "content", "message", "markdown", "completion"];
 656+  const directValues = directKeys.flatMap((fieldName) => extractTextFragments(value[fieldName] as JsonValue));
 657+
 658+  if (directValues.length > 0) {
 659+    return directValues;
 660+  }
 661+
 662+  return Object.values(value).flatMap((entry) => extractTextFragments(entry as JsonValue));
 663+}
 664+
 665+function normalizeClaudeMessageRole(record: JsonObject): string | null {
 666+  const author = asUnknownRecord(record.author);
 667+  const rawRole =
 668+    readStringValue(record, "role")
 669+    ?? readStringValue(record, "sender")
 670+    ?? readUnknownString(author, ["role", "sender"])
 671+    ?? readStringValue(record, "type");
 672+
 673+  if (rawRole == null) {
 674+    return null;
 675+  }
 676+
 677+  const normalized = rawRole.toLowerCase();
 678+
 679+  if (normalized === "human") {
 680+    return "user";
 681+  }
 682+
 683+  if (normalized === "assistant") {
 684+    return "assistant";
 685+  }
 686+
 687+  if (normalized === "user") {
 688+    return "user";
 689+  }
 690+
 691+  return normalized;
 692+}
 693+
 694+function collectClaudeMessages(value: JsonValue | string | null): JsonObject[] {
 695+  const rootValue = typeof value === "string" ? parseBrowserProxyBody(value) : value;
 696+  const messages: JsonObject[] = [];
 697+  const seen = new Set<string>();
 698+
 699+  const walk = (node: JsonValue | null, depth: number): void => {
 700+    if (node == null || depth > 12) {
 701+      return;
 702+    }
 703+
 704+    if (Array.isArray(node)) {
 705+      for (const entry of node) {
 706+        walk(entry, depth + 1);
 707+      }
 708+
 709+      return;
 710+    }
 711+
 712+    if (!isJsonObject(node)) {
 713+      return;
 714+    }
 715+
 716+    const role = normalizeClaudeMessageRole(node);
 717+    const content = extractTextFragments(node).join("\n\n").trim();
 718+
 719+    if (role != null && content !== "") {
 720+      const id =
 721+        readStringValue(node, "uuid")
 722+        ?? readStringValue(node, "id")
 723+        ?? readStringValue(node, "message_uuid");
 724+      const dedupeKey = `${id ?? "anonymous"}|${role}|${content}`;
 725+
 726+      if (!seen.has(dedupeKey)) {
 727+        seen.add(dedupeKey);
 728+        messages.push(compactJsonObject({
 729+          content,
 730+          created_at: normalizeTimestampLike(
 731+            node.created_at
 732+            ?? node.createdAt
 733+            ?? node.updated_at
 734+            ?? node.updatedAt
 735+          ) ?? undefined,
 736+          id: id ?? undefined,
 737+          role
 738+        }));
 739+      }
 740+    }
 741+
 742+    for (const entry of Object.values(node)) {
 743+      walk(entry as JsonValue, depth + 1);
 744+    }
 745+  };
 746+
 747+  walk(rootValue as JsonValue | null, 0);
 748+  return messages;
 749+}
 750+
 751+function buildClaudeRequestPath(template: string, organizationId: string, conversationId?: string): string {
 752+  return template
 753+    .replace("{id}", encodeURIComponent(organizationId))
 754+    .replace("{id}", encodeURIComponent(conversationId ?? ""));
 755+}
 756+
 757+async function requestBrowserProxy(
 758+  context: LocalApiRequestContext,
 759+  input: {
 760+    action: string;
 761+    body?: JsonValue;
 762+    clientId?: string | null;
 763+    headers?: Record<string, string>;
 764+    method: string;
 765+    path: string;
 766+    platform: string;
 767+    timeoutMs?: number;
 768+  }
 769+): Promise<ParsedBrowserProxyResponse> {
 770+  const bridge = requireBrowserBridge(context);
 771+  let apiResponse: BrowserBridgeApiResponse;
 772+
 773+  try {
 774+    apiResponse = await bridge.apiRequest({
 775+      body: input.body,
 776+      clientId: input.clientId,
 777+      headers: input.headers,
 778+      method: input.method,
 779+      path: input.path,
 780+      platform: input.platform,
 781+      timeoutMs: input.timeoutMs ?? DEFAULT_BROWSER_PROXY_TIMEOUT_MS
 782+    });
 783+  } catch (error) {
 784+    throw createBrowserBridgeHttpError(input.action, error);
 785+  }
 786+
 787+  const parsedBody = parseBrowserProxyBody(apiResponse.body);
 788+  const status = apiResponse.status;
 789+
 790+  if (apiResponse.ok === false || (status != null && status >= 400)) {
 791+    throw new LocalApiHttpError(
 792+      status != null && status >= 400 && status < 600 ? status : 502,
 793+      "browser_upstream_error",
 794+      `Browser proxy request failed for ${input.method.toUpperCase()} ${input.path}.`,
 795+      compactJsonObject({
 796+        bridge_client_id: apiResponse.clientId,
 797+        bridge_request_id: apiResponse.id,
 798+        platform: input.platform,
 799+        upstream_body: parsedBody ?? undefined,
 800+        upstream_error: apiResponse.error ?? undefined,
 801+        upstream_status: status ?? undefined
 802+      })
 803+    );
 804+  }
 805+
 806+  return {
 807+    apiResponse,
 808+    body: parsedBody,
 809+    rootObject: isJsonObject(parsedBody) ? parsedBody : null
 810+  };
 811+}
 812+
 813+async function resolveClaudeOrganization(
 814+  context: LocalApiRequestContext,
 815+  selection: ClaudeBrowserSelection,
 816+  requestedOrganizationId?: string | null,
 817+  timeoutMs?: number
 818+): Promise<ClaudeOrganizationSummary> {
 819+  const normalizedRequestedOrganizationId = normalizeOptionalString(requestedOrganizationId);
 820+  const result = await requestBrowserProxy(context, {
 821+    action: "claude organization resolve",
 822+    clientId: selection.client?.client_id,
 823+    method: "GET",
 824+    path: BROWSER_CLAUDE_ORGANIZATIONS_PATH,
 825+    platform: BROWSER_CLAUDE_PLATFORM,
 826+    timeoutMs
 827+  });
 828+  const organizations = extractArrayObjects(result.body, ["organizations", "items", "entries"])
 829+    .map((entry) => summarizeClaudeOrganization(entry))
 830+    .filter((entry): entry is ClaudeOrganizationSummary => entry != null);
 831+
 832+  if (organizations.length === 0) {
 833+    throw new LocalApiHttpError(
 834+      502,
 835+      "browser_upstream_invalid_response",
 836+      "Claude organization list returned no usable organizations.",
 837+      {
 838+        path: BROWSER_CLAUDE_ORGANIZATIONS_PATH
 839+      }
 840+    );
 841+  }
 842+
 843+  if (normalizedRequestedOrganizationId != null) {
 844+    const matchedOrganization = organizations.find((entry) => entry.id === normalizedRequestedOrganizationId);
 845+
 846+    if (matchedOrganization == null) {
 847+      throw new LocalApiHttpError(
 848+        404,
 849+        "not_found",
 850+        `Claude organization "${normalizedRequestedOrganizationId}" was not found.`,
 851+        {
 852+          organization_id: normalizedRequestedOrganizationId,
 853+          resource: "claude_organization"
 854+        }
 855+      );
 856+    }
 857+
 858+    return matchedOrganization;
 859+  }
 860+
 861+  const currentOrganization = organizations.find((entry) =>
 862+    readUnknownBoolean(asUnknownRecord(entry.raw), ["is_default", "default", "selected", "active"]) === true
 863+  );
 864+
 865+  return currentOrganization ?? organizations[0]!;
 866+}
 867+
 868+async function listClaudeConversations(
 869+  context: LocalApiRequestContext,
 870+  selection: ClaudeBrowserSelection,
 871+  organizationId: string,
 872+  timeoutMs?: number
 873+): Promise<ClaudeConversationSummary[]> {
 874+  const result = await requestBrowserProxy(context, {
 875+    action: "claude conversation list",
 876+    clientId: selection.client?.client_id,
 877+    method: "GET",
 878+    path: buildClaudeRequestPath(BROWSER_CLAUDE_CONVERSATIONS_PATH, organizationId),
 879+    platform: BROWSER_CLAUDE_PLATFORM,
 880+    timeoutMs
 881+  });
 882+
 883+  return extractArrayObjects(result.body, ["chat_conversations", "conversations", "items", "entries"])
 884+    .map((entry) => summarizeClaudeConversation(entry))
 885+    .filter((entry): entry is ClaudeConversationSummary => entry != null);
 886+}
 887+
 888+async function createClaudeConversation(
 889+  context: LocalApiRequestContext,
 890+  selection: ClaudeBrowserSelection,
 891+  organizationId: string,
 892+  timeoutMs?: number
 893+): Promise<ClaudeConversationSummary> {
 894+  const result = await requestBrowserProxy(context, {
 895+    action: "claude conversation create",
 896+    body: {},
 897+    clientId: selection.client?.client_id,
 898+    method: "POST",
 899+    path: buildClaudeRequestPath(BROWSER_CLAUDE_CONVERSATIONS_PATH, organizationId),
 900+    platform: BROWSER_CLAUDE_PLATFORM,
 901+    timeoutMs
 902+  });
 903+  const conversationRecord =
 904+    readJsonObjectField(result.rootObject, "chat_conversation")
 905+    ?? readJsonObjectField(result.rootObject, "conversation")
 906+    ?? readJsonObjectField(readJsonObjectField(result.rootObject, "data"), "chat_conversation")
 907+    ?? readJsonObjectField(readJsonObjectField(result.rootObject, "data"), "conversation")
 908+    ?? result.rootObject;
 909+  const conversation = conversationRecord == null ? null : summarizeClaudeConversation(conversationRecord);
 910+
 911+  if (conversation == null) {
 912+    throw new LocalApiHttpError(
 913+      502,
 914+      "browser_upstream_invalid_response",
 915+      "Claude conversation create returned no usable conversation id.",
 916+      {
 917+        organization_id: organizationId
 918+      }
 919+    );
 920+  }
 921+
 922+  return conversation;
 923+}
 924+
 925+async function resolveClaudeConversation(
 926+  context: LocalApiRequestContext,
 927+  selection: ClaudeBrowserSelection,
 928+  organizationId: string,
 929+  options: {
 930+    conversationId?: string | null;
 931+    createIfMissing?: boolean;
 932+    timeoutMs?: number;
 933+  } = {}
 934+): Promise<ClaudeConversationSummary> {
 935+  const normalizedConversationId = normalizeOptionalString(options.conversationId);
 936+
 937+  if (normalizedConversationId != null) {
 938+    return {
 939+      created_at: null,
 940+      id: normalizedConversationId,
 941+      raw: null,
 942+      title: null,
 943+      updated_at: null
 944+    };
 945+  }
 946+
 947+  const conversations = await listClaudeConversations(context, selection, organizationId, options.timeoutMs);
 948+  const currentConversation = pickLatestClaudeConversation(conversations);
 949+
 950+  if (currentConversation != null) {
 951+    return currentConversation;
 952+  }
 953+
 954+  if (options.createIfMissing) {
 955+    return await createClaudeConversation(context, selection, organizationId, options.timeoutMs);
 956+  }
 957+
 958+  throw new LocalApiHttpError(
 959+    404,
 960+    "not_found",
 961+    "No Claude conversation is available for the selected organization.",
 962+    {
 963+      organization_id: organizationId,
 964+      resource: "claude_conversation"
 965+    }
 966+  );
 967+}
 968+
 969+async function readClaudeConversationCurrentData(
 970+  context: LocalApiRequestContext,
 971+  input: {
 972+    clientId?: string | null;
 973+    conversationId?: string | null;
 974+    createIfMissing?: boolean;
 975+    organizationId?: string | null;
 976+    timeoutMs?: number;
 977+  } = {}
 978+): Promise<{
 979+  client: BrowserBridgeClientSnapshot;
 980+  conversation: ClaudeConversationSummary;
 981+  detail: ParsedBrowserProxyResponse;
 982+  organization: ClaudeOrganizationSummary;
 983+  page: JsonObject;
 984+}> {
 985+  const selection = ensureClaudeBridgeReady(
 986+    selectClaudeBrowserClient(loadBrowserState(context), input.clientId),
 987+    input.clientId
 988+  );
 989+  const organization = await resolveClaudeOrganization(
 990+    context,
 991+    selection,
 992+    input.organizationId,
 993+    input.timeoutMs
 994+  );
 995+  const conversation = await resolveClaudeConversation(
 996+    context,
 997+    selection,
 998+    organization.id,
 999+    {
1000+      conversationId: input.conversationId,
1001+      createIfMissing: input.createIfMissing,
1002+      timeoutMs: input.timeoutMs
1003+    }
1004+  );
1005+  const detail = await requestBrowserProxy(context, {
1006+    action: "claude conversation read",
1007+    clientId: selection.client.client_id,
1008+    method: "GET",
1009+    path: buildClaudeRequestPath(BROWSER_CLAUDE_CONVERSATION_PATH, organization.id, conversation.id),
1010+    platform: BROWSER_CLAUDE_PLATFORM,
1011+    timeoutMs: input.timeoutMs
1012+  });
1013+
1014+  return {
1015+    client: selection.client,
1016+    conversation,
1017+    detail,
1018+    organization,
1019+    page: compactJsonObject({
1020+      client_id: selection.client.client_id,
1021+      credentials: serializeBrowserCredentialSnapshot(selection.credential),
1022+      request_hooks:
1023+        selection.requestHook == null
1024+          ? undefined
1025+          : serializeBrowserRequestHookSnapshot(selection.requestHook)
1026+    })
1027+  };
1028+}
1029+
1030+function buildBrowserStatusData(context: LocalApiRequestContext): JsonObject {
1031+  const browserState = loadBrowserState(context);
1032+  const currentClient =
1033+    browserState.clients.find((client) => client.client_id === browserState.active_client_id)
1034+    ?? [...browserState.clients].sort((left, right) => right.last_message_at - left.last_message_at)[0]
1035+    ?? null;
1036+  const claudeSelection = selectClaudeBrowserClient(browserState);
1037+
1038+  return {
1039+    bridge: {
1040+      active_client_id: browserState.active_client_id,
1041+      active_connection_id: browserState.active_connection_id,
1042+      client_count: browserState.client_count,
1043+      clients: browserState.clients.map(serializeBrowserClientSnapshot),
1044+      status: browserState.client_count > 0 ? "connected" : "disconnected",
1045+      transport: "local_firefox_ws",
1046+      ws_path: browserState.ws_path,
1047+      ws_url: browserState.ws_url
1048+    },
1049+    current_client: currentClient == null ? null : serializeBrowserClientSnapshot(currentClient),
1050+    claude: {
1051+      credentials:
1052+        claudeSelection.credential == null
1053+          ? null
1054+          : serializeBrowserCredentialSnapshot(claudeSelection.credential),
1055+      current_client_id: claudeSelection.client?.client_id ?? null,
1056+      open_url: BROWSER_CLAUDE_ROOT_URL,
1057+      platform: BROWSER_CLAUDE_PLATFORM,
1058+      ready: claudeSelection.client != null && claudeSelection.credential != null,
1059+      request_hooks:
1060+        claudeSelection.requestHook == null
1061+          ? null
1062+          : serializeBrowserRequestHookSnapshot(claudeSelection.requestHook),
1063+      supported: true
1064+    }
1065+  };
1066+}
1067+
1068 function buildCodexRouteCatalog(): JsonObject[] {
1069   return LOCAL_API_ROUTES.filter((route) => isCodexRoute(route)).map((route) => describeRoute(route));
1070 }
1071@@ -1067,13 +2090,17 @@ function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonO
1072       "action_request",
1073       "credentials",
1074       "api_endpoints",
1075-      "client_log"
1076+      "client_log",
1077+      "api_response"
1078     ],
1079     outbound_messages: [
1080       "hello_ack",
1081       "state_snapshot",
1082       "action_result",
1083+      "open_tab",
1084+      "api_request",
1085       "request_credentials",
1086+      "reload",
1087       "error"
1088     ],
1089     path: "/ws/firefox",
1090@@ -1083,6 +2110,30 @@ function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonO
1091   };
1092 }
1093 
1094+function buildBrowserHttpData(snapshot: ConductorRuntimeApiSnapshot): JsonObject {
1095+  return {
1096+    enabled: snapshot.controlApi.firefoxWsUrl != null,
1097+    platform: BROWSER_CLAUDE_PLATFORM,
1098+    route_prefix: "/v1/browser",
1099+    routes: [
1100+      describeRoute(requireRouteDefinition("browser.status")),
1101+      describeRoute(requireRouteDefinition("browser.claude.open")),
1102+      describeRoute(requireRouteDefinition("browser.claude.send")),
1103+      describeRoute(requireRouteDefinition("browser.claude.current")),
1104+      describeRoute(requireRouteDefinition("browser.claude.reload"))
1105+    ],
1106+    transport: {
1107+      http: snapshot.controlApi.localApiBase ?? null,
1108+      websocket: snapshot.controlApi.firefoxWsUrl ?? null
1109+    },
1110+    notes: [
1111+      "All Claude actions go through conductor HTTP first; conductor then forwards them over the local /ws/firefox bridge.",
1112+      "Claude send/current use the Firefox extension's page-internal HTTP proxy instead of DOM automation.",
1113+      "This surface currently supports Claude only and expects a local Firefox bridge client."
1114+    ]
1115+  };
1116+}
1117+
1118 function isHostOperationsRoute(route: LocalApiRouteDefinition): boolean {
1119   return HOST_OPERATIONS_ROUTE_IDS.has(route.id);
1120 }
1121@@ -1319,6 +2370,11 @@ function routeBelongsToSurface(
1122       "service.health",
1123       "service.version",
1124       "system.capabilities",
1125+      "browser.status",
1126+      "browser.claude.open",
1127+      "browser.claude.send",
1128+      "browser.claude.current",
1129+      "browser.claude.reload",
1130       "codex.status",
1131       "codex.sessions.list",
1132       "codex.sessions.read",
1133@@ -1368,9 +2424,11 @@ function buildCapabilitiesData(
1134     workflow: [
1135       "GET /describe",
1136       "GET /v1/capabilities",
1137+      "GET /v1/browser if browser mediation is needed",
1138       "GET /v1/system/state",
1139-      "GET /v1/tasks or /v1/codex",
1140+      "GET /v1/browser/claude/current or /v1/tasks or /v1/codex",
1141       "Use /v1/codex/* for interactive Codex session and turn work",
1142+      "Use /v1/browser/claude/* for Claude page open/send/current over the local Firefox bridge",
1143       "GET /describe/control if local shell/file access is needed",
1144       "Use POST system routes or host operations only when a write/exec is intended"
1145     ],
1146@@ -1384,6 +2442,7 @@ function buildCapabilitiesData(
1147       scheduler_enabled: snapshot.daemon.schedulerEnabled,
1148       started: snapshot.runtime.started
1149     },
1150+    browser: buildBrowserHttpData(snapshot),
1151     transports: {
1152       http: {
1153         auth: buildHttpAuthData(snapshot),
1154@@ -1436,6 +2495,7 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
1155     auth: buildHttpAuthData(snapshot),
1156     system,
1157     websocket: buildFirefoxWebSocketData(snapshot),
1158+    browser: buildBrowserHttpData(snapshot),
1159     codex: buildCodexProxyData(snapshot),
1160     describe_endpoints: {
1161       business: {
1162@@ -1485,6 +2545,12 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
1163         path: "/v1/capabilities",
1164         curl: buildCurlExample(origin, requireRouteDefinition("system.capabilities"))
1165       },
1166+      {
1167+        title: "Inspect the browser bridge surface",
1168+        method: "GET",
1169+        path: "/v1/browser",
1170+        curl: buildCurlExample(origin, requireRouteDefinition("browser.status"))
1171+      },
1172       {
1173         title: "Inspect the codex proxy surface",
1174         method: "GET",
1175@@ -1527,6 +2593,7 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
1176     ],
1177     notes: [
1178       "AI callers should prefer /describe/business for business queries and /describe/control for control actions.",
1179+      "The formal /v1/browser/* surface currently supports Claude only and forwards browser work through the local Firefox bridge.",
1180       "All /v1/codex routes proxy the independent codexd daemon; this process does not host Codex sessions itself.",
1181       "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN>; missing or wrong tokens return 401 JSON.",
1182       "These routes read and mutate the mini node's local truth source directly.",
1183@@ -1569,9 +2636,22 @@ async function handleScopedDescribeRead(
1184       ],
1185       system,
1186       websocket: buildFirefoxWebSocketData(snapshot),
1187+      browser: buildBrowserHttpData(snapshot),
1188       codex: buildCodexProxyData(snapshot),
1189       endpoints: routes.map(describeRoute),
1190       examples: [
1191+        {
1192+          title: "Inspect browser bridge readiness",
1193+          method: "GET",
1194+          path: "/v1/browser",
1195+          curl: buildCurlExample(origin, requireRouteDefinition("browser.status"))
1196+        },
1197+        {
1198+          title: "Read current Claude conversation state",
1199+          method: "GET",
1200+          path: "/v1/browser/claude/current",
1201+          curl: buildCurlExample(origin, requireRouteDefinition("browser.claude.current"))
1202+        },
1203         {
1204           title: "List recent tasks",
1205           method: "GET",
1206@@ -1596,6 +2676,7 @@ async function handleScopedDescribeRead(
1207       ],
1208       notes: [
1209         "This surface is intended to be enough for business-query discovery without reading external docs.",
1210+        "The formal /v1/browser/* surface currently supports Claude only and rides on the local Firefox bridge.",
1211         "All /v1/codex routes proxy the independent codexd daemon instead of an in-process bridge.",
1212         "If you pivot to /describe/control for /v1/exec or /v1/files/*, those host-ops routes require Authorization: Bearer <BAA_SHARED_TOKEN>.",
1213         "Control actions and host-level exec/file operations are intentionally excluded; use /describe/control."
1214@@ -1729,6 +2810,7 @@ async function handleCapabilitiesRead(
1215     system: await buildSystemStateData(repository),
1216     notes: [
1217       "Read routes are safe for discovery and inspection.",
1218+      "The /v1/browser/* surface currently supports Claude only and uses the local Firefox bridge plus page-internal HTTP proxy.",
1219       "All /v1/codex routes proxy the independent codexd daemon over local HTTP.",
1220       "POST /v1/system/* writes the local automation mode immediately.",
1221       "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN> and return 401 JSON on missing or wrong tokens.",
1222@@ -1745,6 +2827,180 @@ async function handleSystemStateRead(context: LocalApiRequestContext): Promise<C
1223   );
1224 }
1225 
1226+async function handleBrowserStatusRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1227+  return buildSuccessEnvelope(context.requestId, 200, buildBrowserStatusData(context));
1228+}
1229+
1230+async function handleBrowserClaudeOpen(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1231+  const body = readBodyObject(context.request, true);
1232+  const clientId = readOptionalStringField(body, "clientId") ?? readOptionalStringField(body, "client_id");
1233+
1234+  try {
1235+    const receipt = requireBrowserBridge(context).openTab({
1236+      clientId,
1237+      platform: BROWSER_CLAUDE_PLATFORM
1238+    });
1239+
1240+    return buildSuccessEnvelope(context.requestId, 200, {
1241+      client_id: receipt.clientId,
1242+      connection_id: receipt.connectionId,
1243+      dispatched_at: receipt.dispatchedAt,
1244+      open_url: BROWSER_CLAUDE_ROOT_URL,
1245+      platform: BROWSER_CLAUDE_PLATFORM,
1246+      type: receipt.type
1247+    });
1248+  } catch (error) {
1249+    throw createBrowserBridgeHttpError("claude open", error);
1250+  }
1251+}
1252+
1253+async function handleBrowserClaudeSend(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1254+  const body = readBodyObject(context.request, true);
1255+  const clientId = readOptionalStringField(body, "clientId") ?? readOptionalStringField(body, "client_id");
1256+  const organizationId =
1257+    readOptionalStringField(body, "organizationId")
1258+    ?? readOptionalStringField(body, "organization_id");
1259+  const conversationId =
1260+    readOptionalStringField(body, "conversationId")
1261+    ?? readOptionalStringField(body, "conversation_id");
1262+  const prompt =
1263+    readOptionalStringField(body, "prompt")
1264+    ?? readOptionalStringField(body, "message")
1265+    ?? readOptionalStringField(body, "input");
1266+  const explicitPath = readOptionalStringField(body, "path");
1267+  const method = readOptionalStringField(body, "method") ?? "POST";
1268+  const headers =
1269+    readOptionalStringMap(body, "headers")
1270+    ?? readOptionalStringMap(body, "request_headers")
1271+    ?? undefined;
1272+  const requestBody =
1273+    readOptionalObjectField(body, "requestBody")
1274+    ?? readOptionalObjectField(body, "request_body")
1275+    ?? (prompt != null ? { prompt } : undefined);
1276+  const timeoutMs = readOptionalTimeoutMs(body, context.url);
1277+
1278+  if (explicitPath == null && prompt == null) {
1279+    throw new LocalApiHttpError(
1280+      400,
1281+      "invalid_request",
1282+      'Field "prompt" is required unless an explicit Claude proxy "path" is provided.'
1283+    );
1284+  }
1285+
1286+  const selection = ensureClaudeBridgeReady(
1287+    selectClaudeBrowserClient(loadBrowserState(context), clientId),
1288+    clientId
1289+  );
1290+  const organization = explicitPath == null
1291+    ? await resolveClaudeOrganization(context, selection, organizationId, timeoutMs)
1292+    : null;
1293+  const conversation = explicitPath == null
1294+    ? await resolveClaudeConversation(context, selection, organization!.id, {
1295+        conversationId,
1296+        createIfMissing: true,
1297+        timeoutMs
1298+      })
1299+    : null;
1300+  const requestPath =
1301+    explicitPath
1302+    ?? buildClaudeRequestPath(BROWSER_CLAUDE_COMPLETION_PATH, organization!.id, conversation!.id);
1303+  const result = await requestBrowserProxy(context, {
1304+    action: "claude send",
1305+    body: requestBody ?? { prompt: "" },
1306+    clientId: selection.client.client_id,
1307+    headers,
1308+    method,
1309+    path: requestPath,
1310+    platform: BROWSER_CLAUDE_PLATFORM,
1311+    timeoutMs
1312+  });
1313+
1314+  return buildSuccessEnvelope(context.requestId, 200, {
1315+    client_id: result.apiResponse.clientId,
1316+    conversation: conversation == null ? null : {
1317+      conversation_id: conversation.id,
1318+      created_at: conversation.created_at,
1319+      title: conversation.title,
1320+      updated_at: conversation.updated_at
1321+    },
1322+    organization: organization == null ? null : {
1323+      organization_id: organization.id,
1324+      name: organization.name
1325+    },
1326+    platform: BROWSER_CLAUDE_PLATFORM,
1327+    proxy: {
1328+      path: requestPath,
1329+      request_body: requestBody ?? null,
1330+      request_id: result.apiResponse.id,
1331+      status: result.apiResponse.status
1332+    },
1333+    response: result.body
1334+  });
1335+}
1336+
1337+async function handleBrowserClaudeCurrent(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1338+  const timeoutMs = readOptionalTimeoutMs({}, context.url);
1339+  const clientId = readOptionalQueryString(context.url, "clientId", "client_id");
1340+  const organizationId = readOptionalQueryString(context.url, "organizationId", "organization_id");
1341+  const conversationId = readOptionalQueryString(context.url, "conversationId", "conversation_id");
1342+  const current = await readClaudeConversationCurrentData(context, {
1343+    clientId,
1344+    conversationId,
1345+    organizationId,
1346+    timeoutMs
1347+  });
1348+
1349+  return buildSuccessEnvelope(context.requestId, 200, {
1350+    conversation: {
1351+      conversation_id: current.conversation.id,
1352+      created_at: current.conversation.created_at,
1353+      title: current.conversation.title,
1354+      updated_at: current.conversation.updated_at
1355+    },
1356+    messages: collectClaudeMessages(current.detail.body),
1357+    organization: {
1358+      name: current.organization.name,
1359+      organization_id: current.organization.id
1360+    },
1361+    page: current.page,
1362+    platform: BROWSER_CLAUDE_PLATFORM,
1363+    proxy: {
1364+      path: buildClaudeRequestPath(
1365+        BROWSER_CLAUDE_CONVERSATION_PATH,
1366+        current.organization.id,
1367+        current.conversation.id
1368+      ),
1369+      request_id: current.detail.apiResponse.id,
1370+      status: current.detail.apiResponse.status
1371+    },
1372+    raw: current.detail.body
1373+  });
1374+}
1375+
1376+async function handleBrowserClaudeReload(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1377+  const body = readBodyObject(context.request, true);
1378+  const clientId = readOptionalStringField(body, "clientId") ?? readOptionalStringField(body, "client_id");
1379+  const reason = readOptionalStringField(body, "reason") ?? "browser_http_reload";
1380+
1381+  try {
1382+    const receipt = requireBrowserBridge(context).reload({
1383+      clientId,
1384+      reason
1385+    });
1386+
1387+    return buildSuccessEnvelope(context.requestId, 200, {
1388+      client_id: receipt.clientId,
1389+      connection_id: receipt.connectionId,
1390+      dispatched_at: receipt.dispatchedAt,
1391+      platform: BROWSER_CLAUDE_PLATFORM,
1392+      reason,
1393+      type: receipt.type
1394+    });
1395+  } catch (error) {
1396+    throw createBrowserBridgeHttpError("claude reload", error);
1397+  }
1398+}
1399+
1400 function buildHostOperationsAuthError(
1401   route: LocalApiRouteDefinition,
1402   reason: SharedTokenAuthFailureReason
1403@@ -2123,6 +3379,16 @@ async function dispatchBusinessRoute(
1404       return handleVersionRead(context.requestId, version);
1405     case "system.capabilities":
1406       return handleCapabilitiesRead(context, version);
1407+    case "browser.status":
1408+      return handleBrowserStatusRead(context);
1409+    case "browser.claude.open":
1410+      return handleBrowserClaudeOpen(context);
1411+    case "browser.claude.send":
1412+      return handleBrowserClaudeSend(context);
1413+    case "browser.claude.current":
1414+      return handleBrowserClaudeCurrent(context);
1415+    case "browser.claude.reload":
1416+      return handleBrowserClaudeReload(context);
1417     case "codex.status":
1418       return handleCodexStatusRead(context);
1419     case "codex.sessions.list":
1420@@ -2295,6 +3561,8 @@ export async function handleConductorHttpRequest(
1421     return await dispatchRoute(
1422       matchedRoute,
1423       {
1424+        browserBridge: context.browserBridge ?? null,
1425+        browserStateLoader: context.browserStateLoader ?? (() => null),
1426         codexdLocalApiBase:
1427           normalizeOptionalString(context.codexdLocalApiBase) ?? context.snapshotLoader().codexd.localApiBase,
1428         fetchImpl: context.fetchImpl ?? globalThis.fetch,
M docs/api/README.md
+23, -2
 1@@ -28,7 +28,7 @@
 2 | 服务 | 地址 | 说明 |
 3 | --- | --- | --- |
 4 | conductor public host | `https://conductor.makefile.so` | 唯一公网入口;VPS Nginx 回源到同一个 `conductor-daemon` local-api |
 5-| conductor-daemon local-api | `BAA_CONDUCTOR_LOCAL_API`,默认可用值如 `http://127.0.0.1:4317` | 本地真相源;承接 describe/health/version/capabilities/system/controllers/tasks/codex/host-ops |
 6+| conductor-daemon local-api | `BAA_CONDUCTOR_LOCAL_API`,默认可用值如 `http://127.0.0.1:4317` | 本地真相源;承接 describe/health/version/capabilities/browser/system/controllers/tasks/codex/host-ops |
 7 | codexd local-api | `BAA_CODEXD_LOCAL_API_BASE`,默认可用值如 `http://127.0.0.1:4319` | 独立 `codexd` 本地服务;支持 `GET /describe` 自描述;`conductor-daemon` 的 `/v1/codex/*` 只代理到这里 |
 8 | conductor-daemon local-firefox-ws | 由 `BAA_CONDUCTOR_LOCAL_API` 派生,例如 `ws://127.0.0.1:4317/ws/firefox` | 本地 Firefox 插件双向 bridge;复用同一个 listener,不单独开公网端口 |
 9 | status-api local view | `http://127.0.0.1:4318` | 本地只读状态 JSON 和 HTML 视图,不承担公网入口角色 |
10@@ -40,7 +40,7 @@
11 1. `GET ${BAA_CONDUCTOR_LOCAL_API}/describe/business` 或 `GET ${BAA_CONDUCTOR_LOCAL_API}/describe/control`
12 2. 如有需要,再看 `GET ${BAA_CONDUCTOR_LOCAL_API}/v1/capabilities`
13 3. 如果是控制动作,再看 `GET ${BAA_CONDUCTOR_LOCAL_API}/v1/system/state`
14-4. 按需查看 `controllers`、`tasks`、`codex`
15+4. 按需查看 `browser`、`controllers`、`tasks`、`codex`
16 5. 如果要做本机 shell / 文件操作,先读 `GET ${BAA_CONDUCTOR_LOCAL_API}/describe/control` 返回里的 `host_operations`,并准备 `Authorization: Bearer <BAA_SHARED_TOKEN>`
17 6. 只有在明确需要写操作时,再调用 `pause` / `resume` / `drain` 或 `host-ops`
18 7. 只有在明确需要浏览器双向通讯时,再手动连接 `/ws/firefox`
19@@ -100,6 +100,26 @@
20 - turn 的回读统一通过 `GET /v1/codex/sessions/:session_id` 里的 `lastTurn*` 和 `recentEvents`
21 - 正式业务面不包含 run/exec 路线
22 
23+### Browser HTTP 接口
24+
25+当前正式浏览器面只支持 Claude,并且只通过 `conductor` HTTP 暴露:
26+
27+| 方法 | 路径 | 说明 |
28+| --- | --- | --- |
29+| `GET` | `/v1/browser` | 读取 Firefox bridge 摘要、当前 client 和 Claude 就绪状态 |
30+| `POST` | `/v1/browser/claude/open` | 打开或聚焦 Claude 标签页 |
31+| `POST` | `/v1/browser/claude/send` | 发起一轮 Claude prompt;由 daemon 转发到本地 `/ws/firefox`,再由插件走页面内 HTTP 代理 |
32+| `GET` | `/v1/browser/claude/current` | 读取当前 Claude 对话内容和页面代理状态 |
33+| `POST` | `/v1/browser/claude/reload` | 请求当前 Claude bridge 页面重载 |
34+
35+Browser 面约定:
36+
37+- 当前只支持 `claude`
38+- `open` 通过本地 WS 下发 `open_tab`
39+- `send` / `current` 优先通过本地 WS 下发 `api_request`,由插件转成本页 HTTP 请求
40+- 如果当前没有活跃 Firefox client,会返回清晰的 `503` JSON 错误
41+- 如果已连接 client 还没拿到 Claude 凭证,会返回 `409` JSON 错误并提示先在浏览器里完成一轮真实请求
42+
43 ## codexd Direct Local API
44 
45 AI 或自动化如果不经过 `conductor-daemon`,应先读取:
46@@ -191,6 +211,7 @@ host-ops 约定:
47 - `state_snapshot.system` 直接复用 `/v1/system/state` 的字段结构
48 - `action_request` 支持 `pause` / `resume` / `drain`
49 - 浏览器发来的 `credentials` / `api_endpoints` 只在服务端保存最小元数据并进入 snapshot,不回显原始 header 值
50+- `/v1/browser/claude/send` 和 `/v1/browser/claude/current` 会复用这条 WS bridge 的 `api_request` / `api_response` 做 Claude 页面内 HTTP 代理
51 
52 详细消息模型和 smoke 示例见:
53 
M docs/api/business-interfaces.md
+42, -0
 1@@ -53,11 +53,28 @@
 2 
 3 | 方法 | 路径 | 作用 |
 4 | --- | --- | --- |
 5+| `GET` | `/v1/browser` | 查看本地 Firefox bridge 摘要、当前 client 和 Claude 就绪状态 |
 6+| `GET` | `/v1/browser/claude/current` | 查看当前 Claude 对话内容和页面代理状态 |
 7 | `GET` | `/v1/controllers?limit=20` | 查看当前 controller 摘要 |
 8 | `GET` | `/v1/tasks?status=queued&limit=20` | 查看任务列表,可按 `status` 过滤 |
 9 | `GET` | `/v1/tasks/:task_id` | 查看单个任务详情 |
10 | `GET` | `/v1/tasks/:task_id/logs?limit=200` | 查看单个任务日志,可按 `run_id` 过滤 |
11 
12+### 浏览器 Claude 写接口
13+
14+| 方法 | 路径 | 作用 |
15+| --- | --- | --- |
16+| `POST` | `/v1/browser/claude/open` | 打开或聚焦 Claude 标签页 |
17+| `POST` | `/v1/browser/claude/send` | 通过 `conductor -> /ws/firefox -> 插件页面内 HTTP 代理` 发起一轮 Claude prompt |
18+| `POST` | `/v1/browser/claude/reload` | 请求当前 Claude bridge 页面重载 |
19+
20+说明:
21+
22+- 当前浏览器面只支持 `claude`
23+- `send` / `current` 不是 DOM 自动化,而是通过插件已有的页面内 HTTP 代理完成
24+- 如果没有活跃 Firefox bridge client,会返回 `503`
25+- 如果 client 还没有 Claude 凭证快照,会返回 `409`
26+
27 ### Codex 会话查询与写入
28 
29 | 方法 | 路径 | 作用 |
30@@ -114,6 +131,30 @@ BASE_URL="http://100.71.210.78:4317"
31 curl "${BASE_URL}/v1/codex"
32 ```
33 
34+```bash
35+BASE_URL="http://100.71.210.78:4317"
36+curl "${BASE_URL}/v1/browser"
37+```
38+
39+```bash
40+BASE_URL="http://100.71.210.78:4317"
41+curl -X POST "${BASE_URL}/v1/browser/claude/open" \
42+  -H 'Content-Type: application/json' \
43+  -d '{}'
44+```
45+
46+```bash
47+BASE_URL="http://100.71.210.78:4317"
48+curl -X POST "${BASE_URL}/v1/browser/claude/send" \
49+  -H 'Content-Type: application/json' \
50+  -d '{"prompt":"Summarize the current repository status."}'
51+```
52+
53+```bash
54+BASE_URL="http://100.71.210.78:4317"
55+curl "${BASE_URL}/v1/browser/claude/current"
56+```
57+
58 ```bash
59 BASE_URL="http://100.71.210.78:4317"
60 curl -X POST "${BASE_URL}/v1/codex/sessions" \
61@@ -159,6 +200,7 @@ curl "${BASE_URL}/v1/tasks/${TASK_ID}/logs?limit=50"
62 ## 当前边界
63 
64 - 业务类接口当前以“只读查询”为主
65+- 浏览器业务面当前只正式支持 Claude,且依赖本地 Firefox bridge 已连接
66 - `/v1/codex/*` 是少数已经正式开放的业务写接口,但后端固定代理到独立 `codexd`
67 - 控制动作例如 `pause` / `resume` / `drain` 不在本文件讨论范围内
68 - 本机能力接口 `/v1/exec`、`/v1/files/read`、`/v1/files/write` 也不在本文件讨论范围内
M docs/api/firefox-local-ws.md
+3, -2
 1@@ -271,8 +271,9 @@ server 行为:
 2 
 3 当前非目标:
 4 
 5-- 这里还没有最终 `/v1/browser/*` HTTP 产品接口
 6-- 本阶段只提供 WS transport、client registry 和 request-response 基础能力
 7+- 不把 `/ws/firefox` 直接暴露成公网产品接口
 8+- Claude 正式 HTTP 面统一收口到 `GET /v1/browser`、`POST /v1/browser/claude/open`、`POST /v1/browser/claude/send`、`GET /v1/browser/claude/current`
 9+- 本文仍只讨论 WS transport、client registry 和 request-response 基础能力
10 
11 ## 最小 smoke
12