- commit
- b17508c
- parent
- 73a70f9
- author
- im_wower
- date
- 2026-03-24 08:33:20 +0800 CST
merge: land BRW003 conductor browser http surface
9 files changed,
+2008,
-18
+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
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+}
+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
+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(
+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,
+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,
+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
+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` 也不在本文件讨论范围内
+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